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).
297 lines
9.5 KiB
TypeScript
297 lines
9.5 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';
|
|
import { homedir, tmpdir, platform } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
const IS_WIN = platform() === 'win32';
|
|
|
|
type EnvLike = Record<string, string | undefined>;
|
|
|
|
interface ClaudeSettings {
|
|
env?: Record<string, unknown>;
|
|
}
|
|
|
|
function normalizeEnvValue(key: string, value: unknown): string | undefined {
|
|
if (value == null) return undefined;
|
|
if (typeof value === 'string') {
|
|
// Filter out empty strings - they cause issues
|
|
if (value.trim() === '') return undefined;
|
|
return value;
|
|
}
|
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
return String(value);
|
|
}
|
|
// ANTHROPIC_CUSTOM_HEADERS can be an object in settings.json — serialize it.
|
|
// Other object values are skipped to prevent "Invalid header name" errors.
|
|
if (typeof value === 'object') {
|
|
if (key === 'ANTHROPIC_CUSTOM_HEADERS') {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readSingleSettingsFile(filePath: string): EnvLike {
|
|
try {
|
|
const raw = readFileSync(filePath, 'utf-8');
|
|
const parsed = JSON.parse(raw) as ClaudeSettings;
|
|
if (!parsed.env || typeof parsed.env !== 'object') return {};
|
|
|
|
const env: EnvLike = {};
|
|
for (const [key, value] of Object.entries(parsed.env)) {
|
|
const normalized = normalizeEnvValue(key, value);
|
|
if (normalized !== undefined) {
|
|
env[key] = normalized;
|
|
}
|
|
}
|
|
return env;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read env from ~/.claude/settings.json and ~/.claude/settings.local.json.
|
|
* Local settings take priority (same as Claude Code's own precedence).
|
|
*/
|
|
function readClaudeSettingsEnv(): EnvLike {
|
|
const claudeDir = join(homedir(), '.claude');
|
|
const base = readSingleSettingsFile(join(claudeDir, 'settings.json'));
|
|
const local = readSingleSettingsFile(join(claudeDir, 'settings.local.json'));
|
|
return { ...base, ...local };
|
|
}
|
|
|
|
/**
|
|
* Validate if a string is valid JSON (for ANTHROPIC_CUSTOM_HEADERS).
|
|
*/
|
|
function isValidJson(str: string): boolean {
|
|
try {
|
|
JSON.parse(str);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* On Windows, Claude Code SDK may fail with EPERM when writing to ~/.claude.json
|
|
* or ~/.claude/ config files. Ensure the directory and config file exist and are writable.
|
|
*/
|
|
function ensureClaudeConfigWritable(): void {
|
|
if (!IS_WIN) return;
|
|
try {
|
|
const claudeDir = join(homedir(), '.claude');
|
|
mkdirSync(claudeDir, { recursive: true });
|
|
// Ensure .claude.json exists — Claude SDK crashes if it can't write/lock it
|
|
const configFile = join(homedir(), '.claude.json');
|
|
if (!existsSync(configFile)) {
|
|
writeFileSync(configFile, '{}', 'utf-8');
|
|
}
|
|
// Ensure credentials.json exists — SDK may crash trying to read/write it
|
|
const credFile = join(claudeDir, 'credentials.json');
|
|
if (!existsSync(credFile)) {
|
|
writeFileSync(credFile, '{}', 'utf-8');
|
|
}
|
|
// Ensure statsig/ cache dir exists — SDK crashes writing feature gate cache
|
|
const statsigDir = join(claudeDir, 'statsig');
|
|
mkdirSync(statsigDir, { recursive: true });
|
|
} catch {
|
|
// Best effort — if we can't fix it, the SDK error hint will guide the user
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build env passed to Claude Agent SDK.
|
|
* Priority: current process env > ~/.claude/settings.json env.
|
|
*/
|
|
export function buildClaudeAgentEnv(): EnvLike {
|
|
// On Windows, pre-create config files to avoid EPERM errors
|
|
ensureClaudeConfigWritable();
|
|
|
|
const fromSettings = readClaudeSettingsEnv();
|
|
const fromProcess = process.env as EnvLike;
|
|
|
|
const merged: EnvLike = {
|
|
...fromSettings,
|
|
...fromProcess,
|
|
};
|
|
|
|
// Validate ANTHROPIC_CUSTOM_HEADERS if it exists - must be valid JSON
|
|
// If invalid, delete it to prevent "Invalid header name" errors
|
|
if (merged.ANTHROPIC_CUSTOM_HEADERS) {
|
|
if (!isValidJson(merged.ANTHROPIC_CUSTOM_HEADERS)) {
|
|
delete merged.ANTHROPIC_CUSTOM_HEADERS;
|
|
}
|
|
}
|
|
|
|
// Compatibility: use ANTHROPIC_AUTH_TOKEN as ANTHROPIC_API_KEY if no API key is set
|
|
const authToken = merged.ANTHROPIC_AUTH_TOKEN;
|
|
if (authToken && !merged.ANTHROPIC_API_KEY) {
|
|
merged.ANTHROPIC_API_KEY = authToken;
|
|
}
|
|
|
|
// Running inside Claude terminal can break nested Claude invocations.
|
|
delete merged.CLAUDECODE;
|
|
|
|
// Remove Electron-specific env vars that may confuse spawned CLI processes
|
|
delete merged.ELECTRON_RUN_AS_NODE;
|
|
delete merged.ELECTRON_RESOURCES_PATH;
|
|
delete merged.CHROME_CRASHPAD_PIPE_NAME;
|
|
|
|
// Enable Agent SDK debug stderr so we can capture CLI crash diagnostics.
|
|
// Without this, the SDK sets stderr to "ignore" and crash output is lost.
|
|
if (!merged.DEBUG_CLAUDE_AGENT_SDK) {
|
|
merged.DEBUG_CLAUDE_AGENT_SDK = '1';
|
|
}
|
|
|
|
if (IS_WIN) {
|
|
// Redirect Claude debug output to temp to avoid write permission issues
|
|
if (!merged.CLAUDE_DEBUG_FILE) {
|
|
const debugPath = getClaudeAgentDebugFilePath();
|
|
if (debugPath) merged.CLAUDE_DEBUG_FILE = debugPath;
|
|
}
|
|
// Set CLAUDE_CONFIG_DIR to a writable temp location as fallback
|
|
// if the default ~/.claude directory is not writable (common in Windows Electron)
|
|
if (!merged.CLAUDE_CONFIG_DIR) {
|
|
try {
|
|
const fallbackDir = join(tmpdir(), 'openpencil-claude-config');
|
|
mkdirSync(fallbackDir, { recursive: true });
|
|
// Only use fallback if we can't write to the default location
|
|
const defaultDir = join(homedir(), '.claude');
|
|
const testFile = join(defaultDir, '.write-test');
|
|
try {
|
|
writeFileSync(testFile, '', 'utf-8');
|
|
const { unlinkSync } = require('node:fs');
|
|
unlinkSync(testFile);
|
|
} catch {
|
|
// Default dir is not writable — use fallback
|
|
merged.CLAUDE_CONFIG_DIR = fallbackDir;
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Force Claude CLI debug output into a writable temp location.
|
|
* This avoids crashes in restricted environments where ~/.claude/debug is not writable.
|
|
*/
|
|
export function getClaudeAgentDebugFilePath(): string | undefined {
|
|
try {
|
|
const dir = join(tmpdir(), 'openpencil-claude-debug');
|
|
mkdirSync(dir, { recursive: true });
|
|
return join(dir, 'claude-agent.log');
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom spawnClaudeCodeProcess for Windows.
|
|
* On Windows, npm-installed CLIs are .cmd/.ps1 scripts that can't be spawned
|
|
* directly without a shell.
|
|
*
|
|
* - `.cmd` files: use `cmd.exe /c` (PowerShell can't run .cmd directly)
|
|
* - `.ps1` files: use `powershell.exe`
|
|
* - `.exe` files: spawned directly without shell
|
|
* - Others: use `cmd.exe /c` as safe default
|
|
*
|
|
* Also captures stderr to the debug file — when Claude Code crashes early,
|
|
* the debug file may be empty but stderr often contains the root cause.
|
|
*/
|
|
export function buildSpawnClaudeCodeProcess() {
|
|
if (process.platform !== 'win32') return undefined;
|
|
return (options: {
|
|
command: string;
|
|
args: string[];
|
|
cwd?: string;
|
|
env: Record<string, string | undefined>;
|
|
signal: AbortSignal;
|
|
}) => {
|
|
const cmd = options.command;
|
|
const isPowerShell = cmd.endsWith('.ps1');
|
|
|
|
let child;
|
|
if (isPowerShell) {
|
|
// For .ps1 scripts, invoke via PowerShell
|
|
const psArgs = ['-ExecutionPolicy', 'Bypass', '-File', cmd, ...options.args];
|
|
child = spawn('powershell.exe', psArgs, {
|
|
cwd: options.cwd,
|
|
env: options.env as NodeJS.ProcessEnv,
|
|
signal: options.signal,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
} else if (cmd.endsWith('.exe')) {
|
|
// .exe files can be spawned directly without shell
|
|
child = spawn(cmd, options.args, {
|
|
cwd: options.cwd,
|
|
env: options.env as NodeJS.ProcessEnv,
|
|
signal: options.signal,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
} else {
|
|
// For .cmd or extensionless binaries, use shell.
|
|
// When shell: true on Windows, empty string args get swallowed.
|
|
// Filter out --setting-sources with empty value to prevent the next
|
|
// flag (e.g. --permission-mode) from being consumed as its value.
|
|
const safeArgs: string[] = [];
|
|
for (let i = 0; i < options.args.length; i++) {
|
|
const arg = options.args[i];
|
|
// Skip --setting-sources followed by an empty string
|
|
if (
|
|
arg === '--setting-sources' &&
|
|
i + 1 < options.args.length &&
|
|
options.args[i + 1] === ''
|
|
) {
|
|
i++; // skip the empty value too
|
|
continue;
|
|
}
|
|
safeArgs.push(arg);
|
|
}
|
|
child = spawn(cmd, safeArgs, {
|
|
cwd: options.cwd,
|
|
env: options.env as NodeJS.ProcessEnv,
|
|
signal: options.signal,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
shell: true,
|
|
windowsHide: true,
|
|
});
|
|
}
|
|
|
|
// Capture stderr to debug file — helps diagnose crashes where the process
|
|
// exits before writing anything to the debug log
|
|
const stderrChunks: Buffer[] = [];
|
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
stderrChunks.push(chunk);
|
|
});
|
|
child.on('exit', (code) => {
|
|
if (code !== 0 && stderrChunks.length > 0) {
|
|
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
|
|
if (stderr) {
|
|
const debugPath = getClaudeAgentDebugFilePath();
|
|
if (debugPath) {
|
|
try {
|
|
appendFileSync(debugPath, `\n[stderr exit=${code}] ${stderr}\n`);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return child;
|
|
};
|
|
}
|