UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

600 lines (521 loc) 17.2 kB
/** * Session Launcher for External Ralph Loop * * Handles spawning Claude Code CLI sessions with proper argument * construction and output capture. * * @implements @.aiwg/requirements/design-ralph-external.md * @security docs/ralph-external-security.md * * SECURITY WARNING * ================ * This module spawns Claude Code with --dangerously-skip-permissions which * BYPASSES ALL PERMISSION PROMPTS. The spawned session can: * * - Read ANY file the process user can read * - Write/modify ANY file the process user can write * - Execute ANY shell command * - Make network requests * - Install packages * - Modify system configuration * * This is required for headless/daemon operation but carries significant * security implications. Sessions run autonomously for extended periods * without human oversight. * * BEFORE USING: * - Read docs/ralph-external-security.md in full * - Understand all risks * - Set appropriate budget and iteration limits * - Ensure clean git state for rollback * - Have monitoring and abort procedures ready */ import { spawn } from 'child_process'; import { createWriteStream, mkdirSync, existsSync, readFileSync, copyFileSync } from 'fs'; import { dirname, join } from 'path'; import { EventEmitter } from 'events'; import { homedir } from 'os'; /** * @typedef {Object} LaunchOptions * @property {string} prompt - The prompt to send * @property {string} sessionId - Session UUID for tracking * @property {string} [model='opus'] - Claude model to use * @property {number} [budget] - Budget per iteration in USD * @property {number} [maxTurns] - Maximum number of turns (requires Claude CLI support) * @property {boolean} [verbose=false] - Enable verbose output * @property {string} [systemPrompt] - System prompt to append * @property {Object} [mcpConfig] - MCP server configuration * @property {string} workingDir - Working directory for session * @property {string} stdoutPath - Path to capture stdout * @property {string} stderrPath - Path to capture stderr * @property {string} outputDir - Directory for session artifacts * @property {number} [timeoutMs] - Timeout in milliseconds */ /** * @typedef {Object} SessionResult * @property {number} exitCode - Process exit code * @property {string} stdoutPath - Path to stdout log * @property {string} stderrPath - Path to stderr log * @property {string} [transcriptPath] - Path to session transcript (if available) * @property {string} [parsedEventsPath] - Path to parsed stream events (if available) * @property {number} duration - Duration in milliseconds * @property {boolean} timedOut - Whether session timed out * @property {string} stdoutBuffer - Last portion of stdout * @property {number} [toolCallCount] - Number of tool calls detected * @property {number} [errorCount] - Number of errors detected */ /** * @typedef {Object} StreamEvent * @property {string} type - Event type (e.g., 'tool_call', 'completion', 'error') * @property {number} timestamp - Unix timestamp * @property {Object} data - Event data */ export class SessionLauncher extends EventEmitter { constructor() { super(); this.currentProcess = null; this.startTime = null; /** @type {import('./lib/provider-adapter.mjs').ProviderAdapter|null} */ this.providerAdapter = null; } /** * Set the provider adapter for CLI abstraction * @param {import('./lib/provider-adapter.mjs').ProviderAdapter} adapter */ setProviderAdapter(adapter) { this.providerAdapter = adapter; } /** * Build Claude CLI arguments * @param {LaunchOptions} options * @returns {string[]} */ buildArgs(options) { const args = [ // SECURITY: This flag bypasses ALL permission prompts // Required for headless operation but enables: // - Unrestricted file read/write // - Arbitrary command execution // - Network access without confirmation // See docs/ralph-external-security.md '--dangerously-skip-permissions', '--print', '--output-format', 'stream-json', '--session-id', options.sessionId, ]; // Verbose mode if (options.verbose) { args.push('--verbose'); } // Model selection if (options.model) { args.push('--model', options.model); } // Budget control if (options.budget) { args.push('--max-budget-usd', String(options.budget)); } // Max turns control (if supported by Claude CLI) if (options.maxTurns) { args.push('--max-turns', String(options.maxTurns)); } // MCP configuration if (options.mcpConfig) { const configJson = typeof options.mcpConfig === 'string' ? options.mcpConfig : JSON.stringify(options.mcpConfig); args.push('--mcp-config', configJson); } // System prompt injection if (options.systemPrompt) { args.push('--append-system-prompt', options.systemPrompt); } // The prompt itself args.push(options.prompt); return args; } /** * Launch a Claude Code session * @param {LaunchOptions} options * @returns {Promise<SessionResult>} */ async launch(options) { const sessionResult = await this._launchSession(options); // Post-session artifact capture await this._captureSessionArtifacts(options, sessionResult); return sessionResult; } /** * Internal method to launch session and capture basic output * @private * @param {LaunchOptions} options * @returns {Promise<SessionResult>} */ _launchSession(options) { return new Promise((resolve, reject) => { // Ensure output directories exist mkdirSync(dirname(options.stdoutPath), { recursive: true }); mkdirSync(dirname(options.stderrPath), { recursive: true }); if (options.outputDir) { mkdirSync(options.outputDir, { recursive: true }); } // Use adapter for args if available, otherwise use legacy buildArgs const args = this.providerAdapter ? this.providerAdapter.buildSessionArgs({ prompt: options.prompt, sessionId: options.sessionId, model: options.model, budget: options.budget, maxTurns: options.maxTurns, verbose: options.verbose, systemPrompt: options.systemPrompt, mcpConfig: options.mcpConfig, }) : this.buildArgs(options); this.startTime = Date.now(); // Create write streams for output capture const stdoutStream = createWriteStream(options.stdoutPath); const stderrStream = createWriteStream(options.stderrPath); // Buffer for last portion of stdout (for quick analysis) let stdoutBuffer = ''; const maxBufferSize = 100000; // 100KB // Spawn process using provider adapter or legacy Claude defaults const binary = this.providerAdapter ? this.providerAdapter.getBinary() : 'claude'; const envOverrides = this.providerAdapter ? this.providerAdapter.getEnvOverrides() : { CI: 'true' }; this.currentProcess = spawn(binary, args, { cwd: options.workingDir, env: { ...process.env, ...envOverrides, }, }); const child = this.currentProcess; // Capture stdout child.stdout.on('data', (chunk) => { stdoutStream.write(chunk); stdoutBuffer += chunk.toString(); // Keep buffer size manageable if (stdoutBuffer.length > maxBufferSize) { stdoutBuffer = stdoutBuffer.slice(-maxBufferSize); } this.emit('stdout', chunk); }); // Capture stderr child.stderr.on('data', (chunk) => { stderrStream.write(chunk); this.emit('stderr', chunk); }); // Handle timeout let timeoutId = null; let timedOut = false; if (options.timeoutMs) { timeoutId = setTimeout(() => { timedOut = true; this.emit('timeout'); child.kill('SIGTERM'); // Force kill after 5 seconds if still running setTimeout(() => { if (!child.killed) { child.kill('SIGKILL'); } }, 5000); }, options.timeoutMs); } // Handle process completion child.on('close', (code) => { if (timeoutId) { clearTimeout(timeoutId); } const duration = Date.now() - this.startTime; this.currentProcess = null; // Close streams stdoutStream.end(); stderrStream.end(); const result = { exitCode: code || 0, stdoutPath: options.stdoutPath, stderrPath: options.stderrPath, duration, timedOut, stdoutBuffer, }; this.emit('complete', result); resolve(result); }); // Handle process errors child.on('error', (err) => { if (timeoutId) { clearTimeout(timeoutId); } const duration = Date.now() - this.startTime; this.currentProcess = null; // Close streams stdoutStream.end(); stderrStream.end(); this.emit('error', err); reject(err); }); this.emit('started', { pid: child.pid, args }); }); } /** * Capture session artifacts after completion * @private * @param {LaunchOptions} options * @param {SessionResult} result */ async _captureSessionArtifacts(options, result) { if (!options.outputDir) { return; // No output directory specified } try { // Copy session transcript if available const transcriptPath = await this.copySessionTranscript( options.sessionId, options.workingDir, options.outputDir ); if (transcriptPath) { result.transcriptPath = transcriptPath; } // Parse stream events from stdout const { path: eventsPath, stats } = await this.parseStreamEvents( options.stdoutPath, options.outputDir ); if (eventsPath) { result.parsedEventsPath = eventsPath; result.toolCallCount = stats.toolCallCount; result.errorCount = stats.errorCount; } } catch (err) { // Log but don't fail the session this.emit('artifact-error', err); } } /** * Copy session transcript from Claude's project directory * * Claude stores session transcripts at: * ~/.claude/projects/{encoded-path}/{session-id}.jsonl * * Path encoding: Replace `/` with `-`, prepend `-` * Example: /foo/bar → -foo-bar * * @param {string} sessionId - Session UUID * @param {string} workingDir - Working directory path * @param {string} outputDir - Destination directory * @returns {Promise<string|null>} Path to copied transcript or null if not found */ async copySessionTranscript(sessionId, workingDir, outputDir) { try { // Use adapter for transcript path if available let sourcePath; if (this.providerAdapter) { sourcePath = this.providerAdapter.getTranscriptPath(sessionId, workingDir); if (!sourcePath) { // Provider doesn't support transcripts this.emit('transcript-not-found', { reason: 'Provider does not support transcripts' }); return null; } } else { // Legacy Claude-specific path const encodedPath = workingDir.replace(/\//g, '-'); sourcePath = join( homedir(), '.claude', 'projects', encodedPath, `${sessionId}.jsonl` ); } // Check if transcript exists if (!existsSync(sourcePath)) { this.emit('transcript-not-found', { sourcePath }); return null; } // Copy to output directory const destPath = join(outputDir, 'session-transcript.jsonl'); copyFileSync(sourcePath, destPath); this.emit('transcript-copied', { sourcePath, destPath }); return destPath; } catch (err) { this.emit('transcript-error', err); return null; } } /** * Parse stream-json events from stdout capture * * Extracts structured events from Claude's stream-json output format. * Tracks tool calls, completions, and errors. * * @param {string} stdoutPath - Path to stdout capture file * @param {string} outputDir - Directory to save parsed events * @returns {Promise<{path: string|null, stats: Object}>} Parsed events path and statistics */ async parseStreamEvents(stdoutPath, outputDir) { const stats = { toolCallCount: 0, errorCount: 0, completionCount: 0, totalEvents: 0, }; try { // Read stdout file const content = readFileSync(stdoutPath, 'utf-8'); // Parse stream-json events (each line is a JSON object) const events = []; const lines = content.split('\n').filter(line => line.trim()); for (const line of lines) { try { const event = JSON.parse(line); // Categorize event const eventType = this._categorizeStreamEvent(event); const structuredEvent = { type: eventType, timestamp: Date.now(), // Could extract from event if available data: event, }; events.push(structuredEvent); stats.totalEvents++; // Update stats if (eventType === 'tool_call') { stats.toolCallCount++; } else if (eventType === 'error') { stats.errorCount++; } else if (eventType === 'completion') { stats.completionCount++; } } catch (parseErr) { // Skip malformed lines continue; } } // Save parsed events const eventsPath = join(outputDir, 'parsed-events.json'); const eventsData = { stats, events, parsedAt: new Date().toISOString(), }; mkdirSync(dirname(eventsPath), { recursive: true }); const fs = await import('fs/promises'); await fs.writeFile(eventsPath, JSON.stringify(eventsData, null, 2)); this.emit('events-parsed', { eventsPath, stats }); return { path: eventsPath, stats }; } catch (err) { this.emit('parse-error', err); return { path: null, stats }; } } /** * Categorize a stream-json event * @private * @param {Object} event - Raw event object * @returns {string} Event type */ _categorizeStreamEvent(event) { // Check for tool-related events first (before checking type field) // This handles events like { type: 'tool_use', name: 'read_file' } if (event.type === 'tool_use' || event.tool || event.tool_use || event.name?.includes('tool')) { return 'tool_call'; } // Check for error events // Note: message can be a string or object, so check type before calling includes if (event.type === 'error' || event.error || (typeof event.message === 'string' && event.message.includes('error'))) { return 'error'; } // Check for other common type fields if (event.type) { return event.type; } // Heuristic categorization based on content if (event.stop_reason || event.content?.some?.(c => c.type === 'text')) { return 'completion'; } if (event.delta || event.content_block_delta) { return 'content_delta'; } if (event.message_start || event.content_block_start) { return 'start'; } if (event.message_stop || event.content_block_stop) { return 'stop'; } return 'unknown'; } /** * Get current process PID * @returns {number|null} */ getPid() { return this.currentProcess?.pid || null; } /** * Check if a process is running * @returns {boolean} */ isRunning() { return this.currentProcess !== null && !this.currentProcess.killed; } /** * Kill current process * @param {string} [signal='SIGTERM'] */ kill(signal = 'SIGTERM') { if (this.currentProcess && !this.currentProcess.killed) { this.currentProcess.kill(signal); } } /** * Get elapsed time since start * @returns {number|null} */ getElapsed() { return this.startTime ? Date.now() - this.startTime : null; } } /** * Check if Claude CLI is available * @returns {Promise<boolean>} */ export async function isClaudeAvailable() { return new Promise((resolve) => { const child = spawn('claude', ['--version'], { stdio: 'pipe', }); child.on('close', (code) => { resolve(code === 0); }); child.on('error', () => { resolve(false); }); }); } /** * Get Claude CLI version * @returns {Promise<string|null>} */ export async function getClaudeVersion() { return new Promise((resolve) => { let output = ''; const child = spawn('claude', ['--version'], { stdio: 'pipe', }); child.stdout.on('data', (chunk) => { output += chunk.toString(); }); child.on('close', (code) => { if (code === 0) { resolve(output.trim()); } else { resolve(null); } }); child.on('error', () => { resolve(null); }); }); } export default SessionLauncher;