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).
419 lines
12 KiB
TypeScript
419 lines
12 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
type ThinkingMode = 'adaptive' | 'disabled' | 'enabled';
|
|
type ThinkingEffort = 'low' | 'medium' | 'high' | 'max';
|
|
|
|
interface CodexExecOptions {
|
|
model?: string;
|
|
systemPrompt?: string;
|
|
thinkingMode?: ThinkingMode;
|
|
thinkingBudgetTokens?: number;
|
|
effort?: ThinkingEffort;
|
|
timeoutMs?: number;
|
|
/** Paths to temporary image files to reference in the prompt */
|
|
imageFiles?: string[];
|
|
}
|
|
|
|
interface CodexCliResult {
|
|
text?: string;
|
|
error?: string;
|
|
}
|
|
|
|
const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000;
|
|
|
|
/**
|
|
* Allowlist-based env filter for Codex CLI subprocess.
|
|
* Only passes through safe system vars and provider-specific prefixes.
|
|
* Prevents leaking secrets like ANTHROPIC_API_KEY, AWS_SECRET_KEY, GITHUB_TOKEN, etc.
|
|
*/
|
|
const CODEX_ENV_ALLOWLIST = new Set([
|
|
'PATH',
|
|
'HOME',
|
|
'TERM',
|
|
'LANG',
|
|
'SHELL',
|
|
'TMPDIR',
|
|
// Windows-essential vars
|
|
'SYSTEMROOT',
|
|
'COMSPEC',
|
|
'USERPROFILE',
|
|
'APPDATA',
|
|
'LOCALAPPDATA',
|
|
'PATHEXT',
|
|
'SYSTEMDRIVE',
|
|
'TEMP',
|
|
'TMP',
|
|
'HOMEDRIVE',
|
|
'HOMEPATH',
|
|
]);
|
|
|
|
/**
|
|
* Extract provider-declared env_key entries from ~/.codex/config.toml.
|
|
*
|
|
* This preserves the default safety boundary of not forwarding sensitive
|
|
* environment variables automatically, while still letting user-defined
|
|
* Codex providers opt in through config.toml as the single source of truth.
|
|
*/
|
|
export function extractCodexConfigEnvKeys(configToml: string): string[] {
|
|
return Array.from(
|
|
new Set(
|
|
Array.from(configToml.matchAll(/^\s*env_key\s*=\s*"([^"]+)"\s*$/gm), (match) => match[1]),
|
|
),
|
|
);
|
|
}
|
|
|
|
function loadCodexConfigEnvKeys(): string[] {
|
|
const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
const configPath = join(codexHome, 'config.toml');
|
|
try {
|
|
return extractCodexConfigEnvKeys(readFileSync(configPath, 'utf-8'));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function filterCodexEnv(
|
|
env: Record<string, string | undefined>,
|
|
extraAllowedKeys: Iterable<string> = [],
|
|
): Record<string, string | undefined> {
|
|
const result: Record<string, string | undefined> = {};
|
|
const extraAllowed = new Set(extraAllowedKeys);
|
|
for (const [k, v] of Object.entries(env)) {
|
|
if (
|
|
CODEX_ENV_ALLOWLIST.has(k) ||
|
|
extraAllowed.has(k) ||
|
|
k.startsWith('OPENAI_') ||
|
|
k.startsWith('CODEX_')
|
|
) {
|
|
result[k] = v;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function runCodexExec(
|
|
userPrompt: string,
|
|
options: CodexExecOptions = {},
|
|
): Promise<CodexCliResult> {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-codex-'));
|
|
const outputPath = join(tempDir, 'last-message.txt');
|
|
const prompt = buildPrompt(options.systemPrompt, userPrompt, options.imageFiles);
|
|
const codexEffort = resolveCodexEffort(options.thinkingMode, options.effort);
|
|
|
|
const args = [
|
|
'exec',
|
|
'--json',
|
|
'--skip-git-repo-check',
|
|
'--sandbox',
|
|
'read-only',
|
|
'--output-last-message',
|
|
outputPath,
|
|
];
|
|
|
|
if (options.model) {
|
|
args.push('--model', options.model);
|
|
}
|
|
|
|
if (codexEffort) {
|
|
args.push('--config', `model_reasoning_effort=${codexEffort}`);
|
|
}
|
|
|
|
// On Windows, passing long prompts as command-line arguments causes
|
|
// shell escaping issues (PowerShell MissingExpression, special chars).
|
|
// Use codex's stdin mode (`-` as prompt arg) on all platforms — simpler
|
|
// and avoids command-line length limits.
|
|
args.push('-');
|
|
|
|
try {
|
|
const runResult = await executeCodexCommand(
|
|
args,
|
|
options.timeoutMs ?? DEFAULT_CODEX_TIMEOUT_MS,
|
|
prompt,
|
|
);
|
|
const finalText = await readFile(outputPath, 'utf-8').catch(() => '');
|
|
const normalizedText = finalText.trim() || runResult.text.trim();
|
|
|
|
if (normalizedText) {
|
|
return { text: normalizedText };
|
|
}
|
|
|
|
if (runResult.errors.length > 0) {
|
|
return { error: runResult.errors.join('; ') };
|
|
}
|
|
|
|
return { error: 'Codex returned no output.' };
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : 'Codex execution failed' };
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
export async function* streamCodexExec(
|
|
userPrompt: string,
|
|
options: CodexExecOptions = {},
|
|
): AsyncGenerator<{ type: 'text'; content: string } | { type: 'error'; content: string }> {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-codex-'));
|
|
const prompt = buildPrompt(options.systemPrompt, userPrompt, options.imageFiles);
|
|
const codexEffort = resolveCodexEffort(options.thinkingMode, options.effort);
|
|
|
|
const args = [
|
|
'exec',
|
|
'--json',
|
|
'--skip-git-repo-check',
|
|
'--sandbox',
|
|
'read-only',
|
|
'-',
|
|
...(options.model ? ['--model', options.model] : []),
|
|
...(codexEffort ? ['--config', `model_reasoning_effort=${codexEffort}`] : []),
|
|
];
|
|
|
|
const child = spawn('codex', args, {
|
|
env: filterCodexEnv(
|
|
process.env as Record<string, string | undefined>,
|
|
loadCodexConfigEnvKeys(),
|
|
),
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
...(process.platform === 'win32' && { shell: true }),
|
|
});
|
|
|
|
if (child.stdin) {
|
|
child.stdin.write(prompt);
|
|
child.stdin.end();
|
|
}
|
|
|
|
try {
|
|
let stdoutBuffer = '';
|
|
for await (const chunk of child.stdout!) {
|
|
stdoutBuffer += chunk.toString('utf-8');
|
|
let idx = stdoutBuffer.indexOf('\n');
|
|
while (idx >= 0) {
|
|
const line = stdoutBuffer.slice(0, idx).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(idx + 1);
|
|
if (line) {
|
|
const event = parseCodexJsonLine(line);
|
|
if (event?.text) yield { type: 'text' as const, content: event.text };
|
|
if (event?.error) yield { type: 'error' as const, content: event.error };
|
|
}
|
|
idx = stdoutBuffer.indexOf('\n');
|
|
}
|
|
}
|
|
if (stdoutBuffer.trim()) {
|
|
const event = parseCodexJsonLine(stdoutBuffer.trim());
|
|
if (event?.text) yield { type: 'text' as const, content: event.text };
|
|
if (event?.error) yield { type: 'error' as const, content: event.error };
|
|
}
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function buildPrompt(
|
|
systemPrompt: string | undefined,
|
|
userPrompt: string,
|
|
imageFiles?: string[],
|
|
): string {
|
|
const userText = userPrompt.trim();
|
|
const imageSection =
|
|
imageFiles && imageFiles.length > 0
|
|
? '\n' +
|
|
imageFiles.map((f) => `[Attached image: ${f} — read this file to see the image]`).join('\n')
|
|
: '';
|
|
|
|
if (!systemPrompt?.trim()) {
|
|
return userText + imageSection;
|
|
}
|
|
|
|
return [
|
|
'You are a design generation assistant. Follow the guidelines below to produce the requested output.',
|
|
'',
|
|
'--- GUIDELINES ---',
|
|
systemPrompt.trim(),
|
|
'',
|
|
'--- TASK ---',
|
|
userText + imageSection,
|
|
].join('\n');
|
|
}
|
|
|
|
function resolveCodexEffort(
|
|
thinkingMode: ThinkingMode | undefined,
|
|
effort: ThinkingEffort | undefined,
|
|
): 'low' | 'medium' | 'high' | undefined {
|
|
if (thinkingMode === 'disabled') {
|
|
return 'low';
|
|
}
|
|
|
|
if (effort === 'max') {
|
|
return 'high';
|
|
}
|
|
|
|
if (effort === 'low' || effort === 'medium' || effort === 'high') {
|
|
return effort;
|
|
}
|
|
|
|
if (thinkingMode === 'enabled') {
|
|
return 'medium';
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async function executeCodexCommand(
|
|
args: string[],
|
|
timeoutMs: number,
|
|
stdinText?: string,
|
|
): Promise<{ text: string; errors: string[] }> {
|
|
return await new Promise((resolve, reject) => {
|
|
const codexConfigEnvKeys = loadCodexConfigEnvKeys();
|
|
const child = spawn('codex', args, {
|
|
env: filterCodexEnv(process.env as Record<string, string | undefined>, codexConfigEnvKeys),
|
|
stdio: [stdinText ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
// On Windows, npm-installed CLIs are .cmd scripts — need shell to resolve.
|
|
...(process.platform === 'win32' && { shell: true }),
|
|
});
|
|
|
|
// Pipe prompt via stdin (codex reads from stdin when `-` is the prompt arg)
|
|
if (stdinText && child.stdin) {
|
|
child.stdin.write(stdinText);
|
|
child.stdin.end();
|
|
}
|
|
|
|
let stdoutBuffer = '';
|
|
let stderrBuffer = '';
|
|
let textAccumulator = '';
|
|
const errors: string[] = [];
|
|
|
|
const flushStdoutLine = (line: string) => {
|
|
const event = parseCodexJsonLine(line);
|
|
if (!event) return;
|
|
if (event.text) {
|
|
textAccumulator += event.text;
|
|
}
|
|
if (event.error) {
|
|
errors.push(event.error);
|
|
}
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
child.kill('SIGTERM');
|
|
reject(new Error(`Codex request timed out after ${Math.round(timeoutMs / 1000)}s.`));
|
|
}, timeoutMs);
|
|
|
|
child.stdout!.on('data', (chunk: Buffer) => {
|
|
stdoutBuffer += chunk.toString('utf-8');
|
|
let idx = stdoutBuffer.indexOf('\n');
|
|
while (idx >= 0) {
|
|
const line = stdoutBuffer.slice(0, idx).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(idx + 1);
|
|
if (line) flushStdoutLine(line);
|
|
idx = stdoutBuffer.indexOf('\n');
|
|
}
|
|
});
|
|
|
|
child.stderr!.on('data', (chunk: Buffer) => {
|
|
stderrBuffer += chunk.toString('utf-8');
|
|
});
|
|
|
|
child.on('error', (err) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
clearTimeout(timer);
|
|
|
|
const tail = stdoutBuffer.trim();
|
|
if (tail) {
|
|
flushStdoutLine(tail);
|
|
}
|
|
|
|
if (code === 0) {
|
|
resolve({ text: textAccumulator, errors });
|
|
return;
|
|
}
|
|
|
|
const stderrError = extractCodexCliError(stderrBuffer);
|
|
const fallback = errors[errors.length - 1];
|
|
reject(new Error(stderrError || fallback || `Codex exited with code ${code ?? 'unknown'}.`));
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseCodexJsonLine(line: string): { text?: string; error?: string } | null {
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
|
if (type === 'error') {
|
|
const message = getStringField(parsed, ['message']);
|
|
return { error: message || 'Codex returned an unknown error.' };
|
|
}
|
|
|
|
// Common Codex JSONL stream events include deltas in "delta" or "text".
|
|
const text =
|
|
getStringField(parsed, ['delta']) ||
|
|
getStringField(parsed, ['text']) ||
|
|
getStringField(parsed, ['content']);
|
|
|
|
if (!text) return null;
|
|
return { text };
|
|
}
|
|
|
|
function getStringField(obj: Record<string, unknown>, keys: string[]): string | null {
|
|
for (const key of keys) {
|
|
const val = obj[key];
|
|
if (typeof val === 'string' && val.length > 0) {
|
|
return val;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractCodexCliError(stderr: string): string | null {
|
|
const trimmed = stderr.trim();
|
|
if (!trimmed) return null;
|
|
|
|
const lines = trimmed
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
// 1. Look for "error: ..." lines (simple CLI errors)
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const line = lines[i];
|
|
if (line.toLowerCase().startsWith('error:')) {
|
|
return line.replace(/^error:\s*/i, '').trim();
|
|
}
|
|
}
|
|
|
|
// 2. Look for Codex structured log errors: "<timestamp> ERROR <module>: <message>"
|
|
// These contain the real error (auth failures, API errors, etc.)
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const match = lines[i].match(/\bERROR\s+\S+:\s*(.+)/);
|
|
if (match) {
|
|
const msg = match[1].trim();
|
|
// For auth errors, provide actionable guidance
|
|
if (/refresh token|sign in again|token.*expired|401 Unauthorized/i.test(msg)) {
|
|
return 'Codex authentication expired. Run "codex logout && codex login" to re-authenticate.';
|
|
}
|
|
return msg;
|
|
}
|
|
}
|
|
|
|
// 3. Skip unhelpful "Warning: no last agent message" — surface it only as fallback
|
|
const lastLine = lines[lines.length - 1] ?? null;
|
|
if (lastLine && /^warning:\s*no last agent message/i.test(lastLine)) {
|
|
return 'Codex returned no output. Check "codex login" status or try a different model.';
|
|
}
|
|
|
|
return lastLine;
|
|
}
|