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
JavaScript
/**
* 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;