openpencil/apps/web/server/utils/resolve-claude-agent-env.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

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;
};
}