UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

1,396 lines (1,220 loc) 38 kB
/** * Headless Worker Executor * Enables workers to invoke Claude Code in headless mode with configurable sandbox profiles. * * ADR-020: Headless Worker Integration Architecture * - Integrates with CLAUDE_CODE_HEADLESS and CLAUDE_CODE_SANDBOX_MODE environment variables * - Provides process pool for concurrent execution * - Builds context from file glob patterns * - Supports prompt templates and output parsing * - Implements timeout and graceful error handling * * Key Features: * - Process pool with configurable maxConcurrent * - Context building from file glob patterns with caching * - Prompt template system with context injection * - Output parsing (text, json, markdown) * - Timeout handling with graceful termination * - Execution logging for debugging * - Event emission for monitoring */ import { spawn, execSync, type ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'fs'; import { join, relative } from 'path'; import type { WorkerType } from './worker-daemon.js'; // ============================================ // Type Definitions // ============================================ /** * Headless worker types - workers that use Claude Code AI */ export type HeadlessWorkerType = | 'audit' | 'optimize' | 'testgaps' | 'document' | 'ultralearn' | 'refactor' | 'deepdive' | 'predict'; /** * Local worker types - workers that run locally without AI */ export type LocalWorkerType = 'map' | 'consolidate' | 'benchmark' | 'preload'; /** * Sandbox mode for headless execution */ export type SandboxMode = 'strict' | 'permissive' | 'disabled'; /** * Model types for Claude Code */ export type ModelType = 'sonnet' | 'opus' | 'haiku'; /** * Output format for worker results */ export type OutputFormat = 'text' | 'json' | 'markdown'; /** * Execution mode for workers */ export type ExecutionMode = 'local' | 'headless'; /** * Worker priority levels */ export type WorkerPriority = 'low' | 'normal' | 'high' | 'critical'; // ============================================ // Interfaces // ============================================ /** * Base worker configuration (matching worker-daemon.ts) */ export interface WorkerConfig { type: WorkerType; intervalMs: number; priority: WorkerPriority; description: string; enabled: boolean; } /** * Headless-specific options */ export interface HeadlessOptions { /** Prompt template for Claude Code */ promptTemplate: string; /** Sandbox profile: strict, permissive, or disabled */ sandbox: SandboxMode; /** Model to use: sonnet, opus, or haiku */ model?: ModelType; /** Maximum tokens for output */ maxOutputTokens?: number; /** Timeout in milliseconds (overrides default) */ timeoutMs?: number; /** File glob patterns to include as context */ contextPatterns?: string[]; /** Output parsing format */ outputFormat?: OutputFormat; } /** * Extended worker configuration with headless options */ export interface HeadlessWorkerConfig extends WorkerConfig { /** Execution mode: local or headless */ mode: ExecutionMode; /** Headless-specific options (required when mode is 'headless') */ headless?: HeadlessOptions; } /** * Executor configuration options */ export interface HeadlessExecutorConfig { /** Maximum concurrent headless processes */ maxConcurrent?: number; /** Default timeout in milliseconds */ defaultTimeoutMs?: number; /** Maximum files to include in context */ maxContextFiles?: number; /** Maximum characters per file in context */ maxCharsPerFile?: number; /** Log directory for execution logs */ logDir?: string; /** Whether to cache context between runs */ cacheContext?: boolean; /** Context cache TTL in milliseconds */ cacheTtlMs?: number; } /** * Result from headless execution */ export interface HeadlessExecutionResult { /** Whether execution completed successfully */ success: boolean; /** Raw output from Claude Code */ output: string; /** Parsed output (if outputFormat is json or markdown) */ parsedOutput?: unknown; /** Execution duration in milliseconds */ durationMs: number; /** Estimated tokens used (if available) */ tokensUsed?: number; /** Model used for execution */ model: string; /** Sandbox mode used */ sandboxMode: SandboxMode; /** Worker type that was executed */ workerType: HeadlessWorkerType; /** Timestamp of execution */ timestamp: Date; /** Error message if execution failed */ error?: string; /** Execution ID for tracking */ executionId: string; } /** * Process pool entry */ interface PoolEntry { process: ChildProcess; executionId: string; workerType: HeadlessWorkerType; startTime: Date; timeout: NodeJS.Timeout; } /** * Pending queue entry */ interface QueueEntry { workerType: HeadlessWorkerType; config?: Partial<HeadlessOptions>; resolve: (result: HeadlessExecutionResult) => void; reject: (error: Error) => void; queuedAt: Date; } /** * Context cache entry */ interface CacheEntry { content: string; timestamp: number; patterns: string[]; } /** * Pool status information */ export interface PoolStatus { activeCount: number; queueLength: number; maxConcurrent: number; activeWorkers: Array<{ executionId: string; workerType: HeadlessWorkerType; startTime: Date; elapsedMs: number; }>; queuedWorkers: Array<{ workerType: HeadlessWorkerType; queuedAt: Date; waitingMs: number; }>; } // ============================================ // Constants // ============================================ /** * Array of headless worker types for runtime checking */ export const HEADLESS_WORKER_TYPES: HeadlessWorkerType[] = [ 'audit', 'optimize', 'testgaps', 'document', 'ultralearn', 'refactor', 'deepdive', 'predict', ]; /** * Array of local worker types */ export const LOCAL_WORKER_TYPES: LocalWorkerType[] = [ 'map', 'consolidate', 'benchmark', 'preload', ]; /** * Model ID mapping */ /** * Model ID mapping — use short aliases so they auto-resolve to the latest * snapshot. Hardcoded dated IDs (e.g. claude-sonnet-4-5-20250929) go stale * when Anthropic retires them, causing 100% worker failure (#1431). * * Users can override per-worker via the `model` field in daemon-state.json * or the ANTHROPIC_MODEL environment variable. */ const MODEL_IDS: Record<ModelType, string> = { sonnet: 'sonnet', opus: 'opus', haiku: 'haiku', }; /** * Default headless worker configurations based on ADR-020 */ export const HEADLESS_WORKER_CONFIGS: Record<HeadlessWorkerType, HeadlessWorkerConfig> = { audit: { type: 'audit', mode: 'headless', intervalMs: 30 * 60 * 1000, priority: 'critical', description: 'AI-powered security analysis', enabled: true, headless: { promptTemplate: `Analyze this codebase for security vulnerabilities: - Check for hardcoded secrets (API keys, passwords) - Identify SQL injection risks - Find XSS vulnerabilities - Check for insecure dependencies - Identify authentication/authorization issues Provide a JSON report with: { "vulnerabilities": [{ "severity": "high|medium|low", "file": "...", "line": N, "description": "..." }], "riskScore": 0-100, "recommendations": ["..."] }`, sandbox: 'strict', model: 'haiku', outputFormat: 'json', contextPatterns: ['**/*.ts', '**/*.js', '**/.env*', '**/package.json'], timeoutMs: 5 * 60 * 1000, }, }, optimize: { type: 'optimize', mode: 'headless', intervalMs: 60 * 60 * 1000, priority: 'high', description: 'AI optimization suggestions', enabled: true, headless: { promptTemplate: `Analyze this codebase for performance optimizations: - Identify N+1 query patterns - Find unnecessary re-renders in React - Suggest caching opportunities - Identify memory leaks - Find redundant computations Provide actionable suggestions with code examples.`, sandbox: 'permissive', model: 'sonnet', outputFormat: 'markdown', contextPatterns: ['src/**/*.ts', 'src/**/*.tsx'], timeoutMs: 10 * 60 * 1000, }, }, testgaps: { type: 'testgaps', mode: 'headless', intervalMs: 60 * 60 * 1000, priority: 'normal', description: 'AI test gap analysis', enabled: true, headless: { promptTemplate: `Analyze test coverage and identify gaps: - Find untested functions and classes - Identify edge cases not covered - Suggest new test scenarios - Check for missing error handling tests - Identify integration test gaps For each gap, provide a test skeleton.`, sandbox: 'permissive', model: 'sonnet', outputFormat: 'markdown', contextPatterns: ['src/**/*.ts', 'tests/**/*.ts', '__tests__/**/*.ts'], timeoutMs: 10 * 60 * 1000, }, }, document: { type: 'document', mode: 'headless', intervalMs: 120 * 60 * 1000, priority: 'low', description: 'AI documentation generation', enabled: false, headless: { promptTemplate: `Generate documentation for undocumented code: - Add JSDoc comments to functions - Create README sections for modules - Document API endpoints - Add inline comments for complex logic - Generate usage examples Focus on public APIs and exported functions.`, sandbox: 'permissive', model: 'haiku', outputFormat: 'markdown', contextPatterns: ['src/**/*.ts'], timeoutMs: 10 * 60 * 1000, }, }, ultralearn: { type: 'ultralearn', mode: 'headless', intervalMs: 0, // Manual trigger only priority: 'normal', description: 'Deep knowledge acquisition', enabled: false, headless: { promptTemplate: `Deeply analyze this codebase to learn: - Architectural patterns used - Coding conventions - Domain-specific terminology - Common patterns and idioms - Team preferences Provide insights as JSON: { "architecture": { "patterns": [...], "style": "..." }, "conventions": { "naming": "...", "formatting": "..." }, "domains": ["..."], "insights": ["..."] }`, sandbox: 'strict', model: 'opus', outputFormat: 'json', contextPatterns: ['**/*.ts', '**/CLAUDE.md', '**/README.md'], timeoutMs: 15 * 60 * 1000, }, }, refactor: { type: 'refactor', mode: 'headless', intervalMs: 0, // Manual trigger only priority: 'normal', description: 'AI refactoring suggestions', enabled: false, headless: { promptTemplate: `Suggest refactoring opportunities: - Identify code duplication - Suggest better abstractions - Find opportunities for design patterns - Identify overly complex functions - Suggest module reorganization Provide before/after code examples.`, sandbox: 'permissive', model: 'sonnet', outputFormat: 'markdown', contextPatterns: ['src/**/*.ts'], timeoutMs: 10 * 60 * 1000, }, }, deepdive: { type: 'deepdive', mode: 'headless', intervalMs: 0, // Manual trigger only priority: 'normal', description: 'Deep code analysis', enabled: false, headless: { promptTemplate: `Perform deep analysis of this codebase: - Understand data flow - Map dependencies - Identify architectural issues - Find potential bugs - Analyze error handling Provide comprehensive report.`, sandbox: 'strict', model: 'opus', outputFormat: 'markdown', contextPatterns: ['src/**/*.ts'], timeoutMs: 15 * 60 * 1000, }, }, predict: { type: 'predict', mode: 'headless', intervalMs: 10 * 60 * 1000, priority: 'low', description: 'Predictive preloading', enabled: false, headless: { promptTemplate: `Based on recent activity, predict what the developer needs: - Files likely to be edited next - Tests that should be run - Documentation to reference - Dependencies to check Provide preload suggestions as JSON: { "filesToPreload": ["..."], "testsToRun": ["..."], "docsToReference": ["..."], "confidence": 0.0-1.0 }`, sandbox: 'strict', model: 'haiku', outputFormat: 'json', contextPatterns: ['.claude-flow/metrics/*.json'], timeoutMs: 2 * 60 * 1000, }, }, }; /** * Local worker configurations */ export const LOCAL_WORKER_CONFIGS: Record<LocalWorkerType, HeadlessWorkerConfig> = { map: { type: 'map', mode: 'local', intervalMs: 15 * 60 * 1000, priority: 'normal', description: 'Codebase mapping', enabled: true, }, consolidate: { type: 'consolidate', mode: 'local', intervalMs: 30 * 60 * 1000, priority: 'low', description: 'Memory consolidation', enabled: true, }, benchmark: { type: 'benchmark', mode: 'local', intervalMs: 60 * 60 * 1000, priority: 'low', description: 'Performance benchmarking', enabled: false, }, preload: { type: 'preload', mode: 'local', intervalMs: 5 * 60 * 1000, priority: 'low', description: 'Resource preloading', enabled: false, }, }; /** * Combined worker configurations */ export const ALL_WORKER_CONFIGS: HeadlessWorkerConfig[] = [ ...Object.values(HEADLESS_WORKER_CONFIGS), ...Object.values(LOCAL_WORKER_CONFIGS), ]; // ============================================ // Utility Functions // ============================================ /** * Check if a worker type is a headless worker */ export function isHeadlessWorker(type: WorkerType): type is HeadlessWorkerType { return HEADLESS_WORKER_TYPES.includes(type as HeadlessWorkerType); } /** * Check if a worker type is a local worker */ export function isLocalWorker(type: WorkerType): type is LocalWorkerType { return LOCAL_WORKER_TYPES.includes(type as LocalWorkerType); } /** * Get model ID from model type */ export function getModelId(model: ModelType): string { return MODEL_IDS[model]; } /** * Get worker configuration by type */ export function getWorkerConfig(type: WorkerType): HeadlessWorkerConfig | undefined { if (isHeadlessWorker(type)) { return HEADLESS_WORKER_CONFIGS[type]; } if (isLocalWorker(type)) { return LOCAL_WORKER_CONFIGS[type]; } return undefined; } // ============================================ // HeadlessWorkerExecutor Class // ============================================ /** * HeadlessWorkerExecutor - Executes workers using Claude Code in headless mode * * Features: * - Process pool with configurable concurrency limit * - Pending queue for overflow requests * - Context caching with configurable TTL * - Execution logging for debugging * - Event emission for monitoring * - Graceful termination */ export class HeadlessWorkerExecutor extends EventEmitter { private projectRoot: string; private config: Required<HeadlessExecutorConfig>; private processPool: Map<string, PoolEntry> = new Map(); private pendingQueue: QueueEntry[] = []; private contextCache: Map<string, CacheEntry> = new Map(); private claudeCodeAvailable: boolean | null = null; private claudeCodeVersion: string | null = null; constructor(projectRoot: string, options?: HeadlessExecutorConfig) { super(); this.projectRoot = projectRoot; // Merge with defaults this.config = { maxConcurrent: options?.maxConcurrent ?? 2, defaultTimeoutMs: options?.defaultTimeoutMs ?? 5 * 60 * 1000, maxContextFiles: options?.maxContextFiles ?? 20, maxCharsPerFile: options?.maxCharsPerFile ?? 5000, logDir: options?.logDir ?? join(projectRoot, '.claude-flow', 'logs', 'headless'), cacheContext: options?.cacheContext ?? true, cacheTtlMs: options?.cacheTtlMs ?? 60000, // 1 minute default }; // Ensure log directory exists this.ensureLogDir(); } // ============================================ // Public API // ============================================ /** * Check if Claude Code CLI is available */ async isAvailable(): Promise<boolean> { if (this.claudeCodeAvailable !== null) { return this.claudeCodeAvailable; } try { const output = execSync('claude --version', { encoding: 'utf-8', stdio: 'pipe', timeout: 5000, windowsHide: true, // Prevent phantom console windows on Windows }); this.claudeCodeAvailable = true; this.claudeCodeVersion = output.trim(); this.emit('status', { available: true, version: this.claudeCodeVersion }); return true; } catch { this.claudeCodeAvailable = false; this.emit('status', { available: false }); return false; } } /** * Get Claude Code version */ async getVersion(): Promise<string | null> { await this.isAvailable(); return this.claudeCodeVersion; } /** * Execute a headless worker */ async execute( workerType: HeadlessWorkerType, configOverrides?: Partial<HeadlessOptions> ): Promise<HeadlessExecutionResult> { const baseConfig = HEADLESS_WORKER_CONFIGS[workerType]; if (!baseConfig) { throw new Error(`Unknown headless worker type: ${workerType}`); } // Check availability const available = await this.isAvailable(); if (!available) { const result = this.createErrorResult( workerType, 'Claude Code CLI not available. Install with: npm install -g @anthropic-ai/claude-code' ); this.emit('error', result); return result; } // Check concurrent limit if (this.processPool.size >= this.config.maxConcurrent) { // Queue the request return new Promise((resolve, reject) => { const entry: QueueEntry = { workerType, config: configOverrides, resolve, reject, queuedAt: new Date(), }; this.pendingQueue.push(entry); this.emit('queued', { workerType, queuePosition: this.pendingQueue.length, }); }); } // Execute immediately return this.executeInternal(workerType, configOverrides); } /** * Get pool status */ /** * #1855: return the PIDs of all currently-running headless worker * children. Used by `WorkerDaemon` to snapshot active child PIDs to * disk so the next lifetime can reap orphans after a hard crash. */ getActiveChildPids(): number[] { const out: number[] = []; for (const entry of this.processPool.values()) { const pid = entry.process?.pid; if (typeof pid === 'number' && pid > 0) out.push(pid); } return out; } getPoolStatus(): PoolStatus { const now = Date.now(); return { activeCount: this.processPool.size, queueLength: this.pendingQueue.length, maxConcurrent: this.config.maxConcurrent, activeWorkers: Array.from(this.processPool.values()).map((entry) => ({ executionId: entry.executionId, workerType: entry.workerType, startTime: entry.startTime, elapsedMs: now - entry.startTime.getTime(), })), queuedWorkers: this.pendingQueue.map((entry) => ({ workerType: entry.workerType, queuedAt: entry.queuedAt, waitingMs: now - entry.queuedAt.getTime(), })), }; } /** * Get number of active executions */ getActiveCount(): number { return this.processPool.size; } /** * Cancel a running execution */ cancel(executionId: string): boolean { const entry = this.processPool.get(executionId); if (!entry) { return false; } clearTimeout(entry.timeout); entry.process.kill('SIGTERM'); this.processPool.delete(executionId); this.emit('cancelled', { executionId }); // Process next in queue this.processQueue(); return true; } /** * Cancel all running executions */ cancelAll(): number { let cancelled = 0; // Cancel active processes (convert to array to avoid iterator issues) const entries = Array.from(this.processPool.entries()); for (const [executionId, entry] of entries) { clearTimeout(entry.timeout); entry.process.kill('SIGTERM'); // SIGKILL fallback after 5s to prevent orphan processes (#1395 Bug 6) setTimeout(() => { try { if (!entry.process.killed) entry.process.kill('SIGKILL'); } catch { /* already dead */ } }, 5000).unref(); this.emit('cancelled', { executionId }); cancelled++; } this.processPool.clear(); // Reject pending queue for (const entry of this.pendingQueue) { entry.reject(new Error('Executor cancelled all executions')); } this.pendingQueue = []; this.emit('allCancelled', { count: cancelled }); return cancelled; } /** * Clear context cache */ clearContextCache(): void { this.contextCache.clear(); this.emit('cacheClear', {}); } /** * Get worker configuration */ getConfig(workerType: HeadlessWorkerType): HeadlessWorkerConfig | undefined { return HEADLESS_WORKER_CONFIGS[workerType]; } /** * Get all headless worker types */ getHeadlessWorkerTypes(): HeadlessWorkerType[] { return [...HEADLESS_WORKER_TYPES]; } /** * Get all local worker types */ getLocalWorkerTypes(): LocalWorkerType[] { return [...LOCAL_WORKER_TYPES]; } // ============================================ // Private Methods // ============================================ /** * Ensure log directory exists */ private ensureLogDir(): void { try { if (!existsSync(this.config.logDir)) { mkdirSync(this.config.logDir, { recursive: true }); } } catch (error) { this.emit('warning', { message: 'Failed to create log directory', error }); } } /** * Internal execution logic */ private async executeInternal( workerType: HeadlessWorkerType, configOverrides?: Partial<HeadlessOptions> ): Promise<HeadlessExecutionResult> { const baseConfig = HEADLESS_WORKER_CONFIGS[workerType]; const headless = { ...baseConfig.headless!, ...configOverrides }; const startTime = Date.now(); const executionId = `${workerType}_${startTime}_${Math.random().toString(36).slice(2, 8)}`; this.emit('start', { executionId, workerType, config: headless }); try { // Build context from file patterns const context = await this.buildContext(headless.contextPatterns || []); // Build the full prompt const fullPrompt = this.buildPrompt(headless.promptTemplate, context); // Log prompt for debugging this.logExecution(executionId, 'prompt', fullPrompt); // Execute Claude Code headlessly const result = await this.executeClaudeCode(fullPrompt, { sandbox: headless.sandbox, model: headless.model || 'sonnet', timeoutMs: headless.timeoutMs || this.config.defaultTimeoutMs, executionId, workerType, }); // Parse output based on format let parsedOutput: unknown; if (headless.outputFormat === 'json' && result.output) { parsedOutput = this.parseJsonOutput(result.output); } else if (headless.outputFormat === 'markdown' && result.output) { parsedOutput = this.parseMarkdownOutput(result.output); } const executionResult: HeadlessExecutionResult = { success: result.success, output: result.output, parsedOutput, durationMs: Date.now() - startTime, tokensUsed: result.tokensUsed, model: headless.model || 'sonnet', sandboxMode: headless.sandbox, workerType, timestamp: new Date(), executionId, error: result.error, }; // Log result this.logExecution(executionId, 'result', JSON.stringify(executionResult, null, 2)); this.emit('complete', executionResult); return executionResult; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const executionResult = this.createErrorResult(workerType, errorMessage); executionResult.executionId = executionId; executionResult.durationMs = Date.now() - startTime; this.logExecution(executionId, 'error', errorMessage); this.emit('error', executionResult); return executionResult; } finally { // Process next in queue this.processQueue(); } } /** * Process the pending queue */ private processQueue(): void { while ( this.pendingQueue.length > 0 && this.processPool.size < this.config.maxConcurrent ) { const next = this.pendingQueue.shift(); if (!next) break; this.executeInternal(next.workerType, next.config) .then(next.resolve) .catch(next.reject); } } /** * Build context from file patterns */ private async buildContext(patterns: string[]): Promise<string> { if (patterns.length === 0) return ''; // Check cache const cacheKey = patterns.sort().join('|'); if (this.config.cacheContext) { const cached = this.contextCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.config.cacheTtlMs) { return cached.content; } } // Collect files matching patterns const files: string[] = []; for (const pattern of patterns) { const matches = this.simpleGlob(pattern); files.push(...matches); } // Deduplicate and limit const uniqueFiles = Array.from(new Set(files)).slice(0, this.config.maxContextFiles); // Build context const contextParts: string[] = []; for (const file of uniqueFiles) { try { const fullPath = join(this.projectRoot, file); if (!existsSync(fullPath)) continue; const content = readFileSync(fullPath, 'utf-8'); const truncated = content.slice(0, this.config.maxCharsPerFile); const wasTruncated = content.length > this.config.maxCharsPerFile; contextParts.push( `--- ${file}${wasTruncated ? ' (truncated)' : ''} ---\n${truncated}` ); } catch { // Skip unreadable files } } const contextContent = contextParts.join('\n\n'); // Cache the result if (this.config.cacheContext) { this.contextCache.set(cacheKey, { content: contextContent, timestamp: Date.now(), patterns, }); } return contextContent; } /** * Simple glob implementation for file matching */ private simpleGlob(pattern: string): string[] { const results: string[] = []; // Handle simple patterns (no wildcards) if (!pattern.includes('*')) { const fullPath = join(this.projectRoot, pattern); if (existsSync(fullPath)) { results.push(pattern); } return results; } // Parse pattern parts const parts = pattern.split('/'); const scanDir = (dir: string, remainingParts: string[]): void => { if (remainingParts.length === 0) return; if (results.length >= 100) return; // Limit results try { const fullDir = join(this.projectRoot, dir); if (!existsSync(fullDir)) return; const entries = readdirSync(fullDir, { withFileTypes: true }); const currentPart = remainingParts[0]; const isLastPart = remainingParts.length === 1; for (const entry of entries) { // Skip common non-code directories if ( entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build' || entry.name === 'coverage' || entry.name === '.next' || entry.name === '.cache' ) { continue; } const entryPath = dir ? `${dir}/${entry.name}` : entry.name; if (currentPart === '**') { // Recursive glob if (entry.isDirectory()) { scanDir(entryPath, remainingParts); // Continue with ** scanDir(entryPath, remainingParts.slice(1)); // Try next part } else if (entry.isFile() && remainingParts.length > 1) { // Check if file matches next pattern part const nextPart = remainingParts[1]; if (this.matchesPattern(entry.name, nextPart)) { results.push(entryPath); } } } else if (this.matchesPattern(entry.name, currentPart)) { if (isLastPart && entry.isFile()) { results.push(entryPath); } else if (!isLastPart && entry.isDirectory()) { scanDir(entryPath, remainingParts.slice(1)); } } } } catch { // Skip unreadable directories } }; scanDir('', parts); return results; } /** * Match filename against a simple pattern */ private matchesPattern(name: string, pattern: string): boolean { if (pattern === '*') return true; if (pattern === '**') return true; // Handle *.ext patterns if (pattern.startsWith('*.')) { return name.endsWith(pattern.slice(1)); } // Handle prefix* patterns if (pattern.endsWith('*')) { return name.startsWith(pattern.slice(0, -1)); } // Handle *suffix patterns if (pattern.startsWith('*')) { return name.endsWith(pattern.slice(1)); } // Exact match return name === pattern; } /** * Build full prompt with context */ private buildPrompt(template: string, context: string): string { if (!context) { return `${template} ## Instructions Analyze the codebase and provide your response following the format specified in the task.`; } return `${template} ## Codebase Context ${context} ## Instructions Analyze the above codebase context and provide your response following the format specified in the task.`; } /** * Execute Claude Code in headless mode */ private executeClaudeCode( prompt: string, options: { sandbox: SandboxMode; model: ModelType; timeoutMs: number; executionId: string; workerType: HeadlessWorkerType; } ): Promise<{ success: boolean; output: string; tokensUsed?: number; error?: string }> { return new Promise((resolve) => { const env: Record<string, string> = { ...(process.env as Record<string, string>), CLAUDE_CODE_HEADLESS: 'true', CLAUDE_CODE_SANDBOX_MODE: options.sandbox, // Fix #1395 Bug 2: Workers fail inside active Claude Code session. // Claude Code detects nested sessions and exits immediately. // Setting CLAUDE_ENTRYPOINT=worker bypasses the nested-session check, // and unsetting CLAUDE_SESSION_ID prevents parent session detection. CLAUDE_ENTRYPOINT: 'worker', }; // Remove parent session markers so the child doesn't detect a "nested" session delete env.CLAUDE_SESSION_ID; delete env.CLAUDE_PARENT_SESSION_ID; // Set model // Resolve model: user env override > config override > default alias env.ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || MODEL_IDS[options.model]; // Spawn claude CLI process. #1852: previously the prompt was passed // as a positional CLI arg. On Windows `claude` resolves to // `claude.cmd`, which Node refuses to exec directly (CVE-2024-27980 // mitigation) — it routes through `cmd.exe /d /s /c`, which then // re-tokenizes the entire command line including the prompt. // Source-code prompts contain `>` `<` `&` `|` (arrow functions, // comparisons, redirections) — cmd.exe parses those as redirects // and creates zero-byte files in cwd named after the next token // (`controller.abort()`, `{const`, `0`, `HTTP`, etc.). // // Fix: pipe the prompt via stdin instead. `child.stdin.end(prompt)` // writes the prompt and closes stdin atomically — the EOF still // unblocks `claude --print` (the original concern in #1395) but no // shell tokenization touches the prompt. const child = spawn('claude', ['--print'], { cwd: this.projectRoot, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, // Prevent phantom console windows on Windows }); try { child.stdin?.end(prompt); } catch { // stdin already closed (e.g. spawn failed) — `error` handler below // will surface the real cause. } // Setup timeout const timeoutHandle = setTimeout(() => { if (this.processPool.has(options.executionId)) { child.kill('SIGTERM'); // Give it a moment to terminate gracefully setTimeout(() => { if (!child.killed) { child.kill('SIGKILL'); } }, 5000); } }, options.timeoutMs); // Track in process pool const poolEntry: PoolEntry = { process: child, executionId: options.executionId, workerType: options.workerType, startTime: new Date(), timeout: timeoutHandle, }; this.processPool.set(options.executionId, poolEntry); let stdout = ''; let stderr = ''; let resolved = false; const cleanup = () => { clearTimeout(timeoutHandle); this.processPool.delete(options.executionId); }; child.stdout?.on('data', (data: Buffer) => { const chunk = data.toString(); stdout += chunk; this.emit('output', { executionId: options.executionId, type: 'stdout', data: chunk, }); }); child.stderr?.on('data', (data: Buffer) => { const chunk = data.toString(); stderr += chunk; this.emit('output', { executionId: options.executionId, type: 'stderr', data: chunk, }); }); child.on('close', (code: number | null) => { if (resolved) return; resolved = true; cleanup(); resolve({ success: code === 0, output: stdout || stderr, error: code !== 0 ? stderr || `Process exited with code ${code}` : undefined, }); }); child.on('error', (error: Error) => { if (resolved) return; resolved = true; cleanup(); resolve({ success: false, output: '', error: error.message, }); }); // Handle timeout setTimeout(() => { if (resolved) return; if (!this.processPool.has(options.executionId)) return; resolved = true; child.kill('SIGTERM'); cleanup(); resolve({ success: false, output: stdout || stderr, error: `Execution timed out after ${options.timeoutMs}ms`, }); }, options.timeoutMs + 100); // Slightly after the kill timeout }); } /** * Parse JSON output from Claude Code */ private parseJsonOutput(output: string): unknown { try { // Try to find JSON in code blocks first const codeBlockMatch = output.match(/```(?:json)?\s*([\s\S]*?)```/); if (codeBlockMatch) { return JSON.parse(codeBlockMatch[1].trim()); } // Try to find any JSON object const jsonMatch = output.match(/\{[\s\S]*\}/); if (jsonMatch) { return JSON.parse(jsonMatch[0]); } // Try direct parse return JSON.parse(output.trim()); } catch { return { parseError: true, rawOutput: output, }; } } /** * Parse markdown output into sections */ private parseMarkdownOutput(output: string): { sections: Array<{ title: string; content: string; level: number }>; codeBlocks: Array<{ language: string; code: string }>; } { const sections: Array<{ title: string; content: string; level: number }> = []; const codeBlocks: Array<{ language: string; code: string }> = []; // Extract code blocks first const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g; let codeMatch; while ((codeMatch = codeBlockRegex.exec(output)) !== null) { codeBlocks.push({ language: codeMatch[1] || 'text', code: codeMatch[2].trim(), }); } // Parse sections const lines = output.split('\n'); let currentSection: { title: string; content: string; level: number } | null = null; for (const line of lines) { const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headerMatch) { if (currentSection) { sections.push(currentSection); } currentSection = { title: headerMatch[2].trim(), content: '', level: headerMatch[1].length, }; } else if (currentSection) { currentSection.content += line + '\n'; } } if (currentSection) { currentSection.content = currentSection.content.trim(); sections.push(currentSection); } return { sections, codeBlocks }; } /** * Create an error result */ private createErrorResult( workerType: HeadlessWorkerType, error: string ): HeadlessExecutionResult { return { success: false, output: '', durationMs: 0, model: 'unknown', sandboxMode: 'strict', workerType, timestamp: new Date(), executionId: `error_${Date.now()}`, error, }; } /** * Log execution details for debugging */ private logExecution( executionId: string, type: 'prompt' | 'result' | 'error', content: string ): void { try { const timestamp = new Date().toISOString(); const logFile = join(this.config.logDir, `${executionId}_${type}.log`); const logContent = `[${timestamp}] ${type.toUpperCase()}\n${'='.repeat(60)}\n${content}\n`; writeFileSync(logFile, logContent); } catch { // Ignore log write errors } } } // Export default export default HeadlessWorkerExecutor;