aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
695 lines • 25.8 kB
JavaScript
/**
* Ralph External Process Launcher
*
* Spawns the external Ralph supervisor as a detached background process
* that survives terminal closure and can be managed across sessions.
*
* @implements @.aiwg/working/issue-ralph-external-completion.md
* @issue #275
* @security docs/ralph-external-security.md
*
* SECURITY WARNING
* ================
* This module launches Claude Code sessions with --dangerously-skip-permissions.
* Spawned sessions can read/write ANY file and execute ANY command without
* user confirmation. Sessions run as detached daemons for extended periods
* without human oversight.
*
* BEFORE USING:
* - Read docs/ralph-external-security.md
* - Understand all security implications
* - Set appropriate limits (budget, iterations, timeout)
* - Ensure clean git state for rollback capability
* - Have monitoring and abort procedures ready
*/
import { spawn } from 'child_process';
import { join } from 'path';
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, copyFileSync, statSync, openSync, readSync, closeSync, } from 'fs';
import { getProviderConfig } from '../agent-spawn.js';
import { AiwgError, EXIT_CODES } from '../errors.js';
/**
* Get the path to the external Ralph orchestrator
*/
export function getOrchestratorPath(frameworkRoot) {
return join(frameworkRoot, 'tools', 'ralph-external', 'index.mjs');
}
/**
* Get the registry directory path
*/
export function getRegistryDir(projectRoot) {
return join(projectRoot, '.aiwg', 'ralph-external');
}
/**
* Get the registry file path
*/
export function getRegistryPath(projectRoot) {
return join(getRegistryDir(projectRoot), 'registry.json');
}
/**
* Generate a unique loop ID
*/
export function generateLoopId(objective) {
const slug = objective
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 30);
const shortId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
return `ralph-${slug}-${shortId}`;
}
/**
* Build command-line arguments for the external Ralph process
*/
export function buildArgs(options) {
const args = [options.objective];
args.push('--completion', options.completionCriteria);
if (options.maxIterations) {
args.push('--max-iterations', String(options.maxIterations));
}
if (options.model) {
args.push('--model', options.model);
}
if (options.budget) {
args.push('--budget', String(options.budget));
}
if (options.timeout) {
args.push('--timeout', String(options.timeout));
}
if (options.mcpConfig) {
args.push('--mcp-config', options.mcpConfig);
}
if (options.giteaIssue) {
args.push('--gitea-issue');
}
if (options.memory !== undefined) {
args.push('--memory', String(options.memory));
}
if (options.crossTask === false) {
args.push('--no-cross-task');
}
if (options.enableAnalytics === false) {
args.push('--no-analytics');
}
if (options.enableBestOutput === false) {
args.push('--no-best-output');
}
if (options.enableEarlyStopping === false) {
args.push('--no-early-stopping');
}
if (options.provider && options.provider !== 'claude') {
args.push('--provider', options.provider);
}
if (options.dangerous) {
args.push('--dangerous');
}
if (options.params) {
args.push('--params', options.params);
}
if (options.verbose) {
args.push('--verbose');
}
if (options.logFile) {
args.push('--log-file', options.logFile);
}
return args;
}
/**
* Enforce `parallelism.max_parallel_ralph_loops` from `.aiwg/aiwg.config`.
* Hard-fails (throws AiwgError) when the count of running + live Ralph loops
* already meets or exceeds the cap. Non-fatal when no config or config read
* fails — caller proceeds with launch.
*
* Extracted so the cap-check logic is unit-testable independently of the
* detached-process spawn machinery.
*
* @implements #1361
*/
export async function assertRalphParallelismCap(projectRoot) {
let cfg;
let resolveParallelism;
try {
const mod = await import('../../config/aiwg-config.js');
resolveParallelism = mod.resolveParallelism;
cfg = await mod.readAiwgConfig(projectRoot);
}
catch {
return; // Config unreadable — non-fatal, let launch proceed
}
if (!cfg)
return;
const resolved = resolveParallelism(cfg.parallelism, cfg.providers[0]);
const launcherRegistry = loadLauncherRegistry(projectRoot);
const activeCount = Object.values(launcherRegistry.loops).filter((l) => l.status === 'running' && isProcessAlive(l.pid)).length;
if (activeCount >= resolved.max_parallel_ralph_loops) {
throw new AiwgError({
code: 'ERR_RALPH_PARALLELISM_CAP',
message: `Project parallelism cap reached: ${activeCount}/${resolved.max_parallel_ralph_loops} Ralph loops already running.`,
hint: `Wait for an existing loop to finish ('aiwg ralph-status'), or bump the cap ('aiwg config set --project parallelism.max_parallel_ralph_loops N').`,
exitCode: EXIT_CODES.GENERAL,
});
}
}
/**
* Launch the external Ralph process as a detached daemon
*/
export async function launchExternalRalph(frameworkRoot, projectRoot, options) {
const orchestratorPath = getOrchestratorPath(frameworkRoot);
if (!existsSync(orchestratorPath)) {
throw new AiwgError({
code: 'ERR_RALPH_ORCHESTRATOR_MISSING',
message: `External Ralph orchestrator not found at: ${orchestratorPath}`,
hint: 'Run `aiwg use ralph` to deploy the orchestrator, or `npm run build` in the dev repo',
exitCode: EXIT_CODES.GENERAL,
});
}
const registryDir = getRegistryDir(projectRoot);
mkdirSync(registryDir, { recursive: true });
// #1361: Enforce project-level max_parallel_ralph_loops cap. Hard fail
// (not queue) — Ralph loops are long-running, silent queueing would be
// surprising. Tell the operator what's running and how to lift the cap.
await assertRalphParallelismCap(projectRoot);
const loopId = options.loopId || generateLoopId(options.objective);
const loopDir = join(registryDir, 'loops', loopId);
mkdirSync(loopDir, { recursive: true });
// Create output file for the detached process
const outputFile = join(loopDir, 'daemon-output.log');
const sessionStdoutFile = join(loopDir, 'outputs', '001-stdout.log');
const sessionStderrFile = join(loopDir, 'outputs', '001-stderr.log');
const promptFile = join(loopDir, 'prompts', '001-prompt.md');
// Build arguments
const args = buildArgs(options);
// Create output file descriptors
const { openSync, closeSync } = await import('fs');
const outFd = openSync(outputFile, 'w');
// Spawn detached process
const child = spawn('node', [orchestratorPath, ...args], {
detached: true,
stdio: ['ignore', outFd, outFd],
cwd: projectRoot,
env: {
...process.env,
RALPH_LOOP_ID: loopId,
RALPH_DETACHED: 'true',
...(options.provider ? { RALPH_PROVIDER: options.provider } : {}),
...(options.dangerous ? {
RALPH_DANGEROUS: 'true',
RALPH_DANGEROUS_FLAG: getProviderConfig(options.provider ?? 'claude').dangerousFlag ?? '',
} : {}),
...(options.params ? { RALPH_EXTRA_PARAMS: options.params } : {}),
},
});
// Detach from parent - let it run independently
child.unref();
closeSync(outFd);
const pid = child.pid;
if (!pid) {
throw new AiwgError({
code: 'ERR_RALPH_SPAWN_FAILED',
message: 'Failed to start external Ralph process — no PID returned by spawn',
hint: 'Check system resources (ulimit -u) and that `node` is on PATH',
exitCode: EXIT_CODES.GENERAL,
});
}
// Record the loop in our launcher registry (backup to the external-multi-loop-state-manager)
const launcherRegistry = loadLauncherRegistry(projectRoot);
launcherRegistry.loops[loopId] = {
loopId,
pid,
objective: options.objective,
completionCriteria: options.completionCriteria,
status: 'running',
startedAt: new Date().toISOString(),
lastUpdate: new Date().toISOString(),
iteration: 0,
maxIterations: options.maxIterations || 5,
outputFile,
sessionStdoutFile,
sessionStderrFile,
promptFile,
provider: options.provider,
};
saveLauncherRegistry(projectRoot, launcherRegistry);
return {
success: true,
loopId,
pid,
message: `agent loop started (${loopId}). Check status: aiwg ralph-status`,
registryPath: getRegistryPath(projectRoot),
};
}
/**
* Load the launcher registry
*/
export function loadLauncherRegistry(projectRoot) {
const registryPath = join(getRegistryDir(projectRoot), 'launcher-registry.json');
if (!existsSync(registryPath)) {
return {
version: '1.0.0',
loops: {},
updatedAt: new Date().toISOString(),
};
}
try {
return JSON.parse(readFileSync(registryPath, 'utf8'));
}
catch {
return {
version: '1.0.0',
loops: {},
updatedAt: new Date().toISOString(),
};
}
}
/**
* Save the launcher registry
*/
export function saveLauncherRegistry(projectRoot, registry) {
const registryDir = getRegistryDir(projectRoot);
mkdirSync(registryDir, { recursive: true });
const registryPath = join(registryDir, 'launcher-registry.json');
registry.updatedAt = new Date().toISOString();
writeFileSync(registryPath, JSON.stringify(registry, null, 2));
}
/**
* Check if a process is still running
*/
export function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
}
catch {
return false;
}
}
/**
* Get status of all agent loops.
* Detects stale entries (>24h without heartbeat) and auto-cleans completed entries.
*/
export function getLoopStatuses(projectRoot, options = {}) {
const registry = loadLauncherRegistry(projectRoot);
const statuses = [];
const staleThresholdMs = 24 * 60 * 60 * 1000; // 24 hours
let registryDirty = false;
for (const [loopId, entry] of Object.entries(registry.loops)) {
// Update status based on process liveness
if (entry.status === 'running' && !isProcessAlive(entry.pid)) {
// Process died - mark as failed unless we can determine it completed
const stateFile = join(getRegistryDir(projectRoot), 'loops', loopId, 'session-state.json');
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
entry.status = state.status || 'failed';
entry.iteration = state.currentIteration || entry.iteration;
}
catch {
entry.status = 'failed';
}
}
else {
entry.status = 'failed';
}
registryDirty = true;
}
// Detect stale entries (>24h since last update)
const lastUpdateAge = Date.now() - new Date(entry.lastUpdate).getTime();
if (lastUpdateAge > staleThresholdMs && entry.status === 'running') {
entry.stale = true;
}
// Auto-clean completed entries if requested
if (options.autoCleanCompleted && (entry.status === 'completed')) {
cleanupCompletedLoop(projectRoot, loopId);
registryDirty = true;
continue; // Don't include in statuses
}
statuses.push(entry);
}
if (registryDirty) {
saveLauncherRegistry(projectRoot, registry);
}
return statuses;
}
/**
* Abort a running agent loop by killing its process
*/
export function abortLoop(projectRoot, loopId) {
const registry = loadLauncherRegistry(projectRoot);
// If no loopId specified, find the most recent running loop
if (!loopId) {
const runningLoops = Object.values(registry.loops)
.filter((l) => l.status === 'running' && isProcessAlive(l.pid))
.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
if (runningLoops.length === 0) {
return { success: false, message: 'No running agent loops found' };
}
loopId = runningLoops[0].loopId;
}
const entry = registry.loops[loopId];
if (!entry) {
return { success: false, message: `Loop not found: ${loopId}` };
}
if (!isProcessAlive(entry.pid)) {
entry.status = 'aborted';
saveLauncherRegistry(projectRoot, registry);
return { success: true, message: `Loop ${loopId} was already stopped` };
}
try {
process.kill(entry.pid, 'SIGTERM');
entry.status = 'aborted';
entry.lastUpdate = new Date().toISOString();
saveLauncherRegistry(projectRoot, registry);
return { success: true, message: `Aborted loop ${loopId} (PID: ${entry.pid})` };
}
catch (error) {
return {
success: false,
message: `Failed to kill process ${entry.pid}: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Resume an interrupted agent loop
*/
export async function resumeLoop(frameworkRoot, projectRoot, loopId, overrides) {
const orchestratorPath = getOrchestratorPath(frameworkRoot);
if (!existsSync(orchestratorPath)) {
throw new AiwgError({
code: 'ERR_RALPH_ORCHESTRATOR_MISSING',
message: `External Ralph orchestrator not found at: ${orchestratorPath}`,
hint: 'Run `aiwg use ralph` to deploy the orchestrator',
exitCode: EXIT_CODES.GENERAL,
});
}
// Build resume arguments
const args = ['--resume'];
if (overrides?.maxIterations) {
args.push('--max-iterations', String(overrides.maxIterations));
}
const registryDir = getRegistryDir(projectRoot);
mkdirSync(registryDir, { recursive: true });
// Find loop to resume
const registry = loadLauncherRegistry(projectRoot);
let targetLoopId = loopId;
if (!targetLoopId) {
// Find most recent non-running loop
const resumable = Object.values(registry.loops)
.filter((l) => l.status !== 'running' || !isProcessAlive(l.pid))
.sort((a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime());
if (resumable.length === 0) {
throw new AiwgError({
code: 'ERR_RALPH_NO_RESUMABLE',
message: 'No loops available to resume',
hint: 'Start a new loop with: aiwg ralph "<objective>"',
exitCode: EXIT_CODES.USAGE,
});
}
targetLoopId = resumable[0].loopId;
}
const entry = registry.loops[targetLoopId];
if (!entry) {
throw new AiwgError({
code: 'ERR_RALPH_LOOP_NOT_FOUND',
message: `Loop not found: ${targetLoopId}`,
hint: 'List available loops with: aiwg ralph-status',
exitCode: EXIT_CODES.USAGE,
});
}
const loopDir = join(registryDir, 'loops', targetLoopId);
const outputFile = join(loopDir, 'daemon-output.log');
// Create output file descriptor
const { openSync, closeSync } = await import('fs');
const outFd = openSync(outputFile, 'a'); // Append mode for resume
// Spawn detached process
const child = spawn('node', [orchestratorPath, ...args], {
detached: true,
stdio: ['ignore', outFd, outFd],
cwd: projectRoot,
env: {
...process.env,
RALPH_LOOP_ID: targetLoopId,
RALPH_DETACHED: 'true',
},
});
child.unref();
closeSync(outFd);
const pid = child.pid;
if (!pid) {
throw new AiwgError({
code: 'ERR_RALPH_SPAWN_FAILED',
message: 'Failed to start external Ralph process — no PID returned by spawn',
hint: 'Check system resources (ulimit -u) and that `node` is on PATH',
exitCode: EXIT_CODES.GENERAL,
});
}
// Update registry
entry.pid = pid;
entry.status = 'running';
entry.lastUpdate = new Date().toISOString();
if (overrides?.maxIterations) {
entry.maxIterations = overrides.maxIterations;
}
saveLauncherRegistry(projectRoot, registry);
return {
success: true,
loopId: targetLoopId,
pid,
message: `Resumed agent loop (${targetLoopId}). Check status: aiwg ralph-status`,
registryPath: getRegistryPath(projectRoot),
};
}
/**
* Clean up a completed loop: remove from launcher-registry and delete working state.
* Preserves completion reports by moving them to the ralph-external root before deletion.
*
* @param projectRoot - Project root directory
* @param loopId - Loop ID to clean up
* @param options - Cleanup options
* @returns Object with success status and details
*/
export function cleanupCompletedLoop(projectRoot, loopId, options = {}) {
const registryDir = getRegistryDir(projectRoot);
const loopDir = join(registryDir, 'loops', loopId);
const preserved = [];
// Preserve completion reports before deleting the loop directory
if (existsSync(loopDir)) {
try {
const files = readdirSync(loopDir);
for (const file of files) {
if (file.startsWith('completion-') && file.endsWith('.md')) {
const dest = join(registryDir, file);
copyFileSync(join(loopDir, file), dest);
preserved.push(dest);
}
}
}
catch {
// Non-fatal: proceed with cleanup even if report preservation fails
}
if (options.archive) {
// Move to archive instead of deleting
const archiveDir = join(registryDir, 'archive', loopId);
mkdirSync(join(registryDir, 'archive'), { recursive: true });
try {
const { renameSync } = require('fs');
renameSync(loopDir, archiveDir);
}
catch {
// If rename fails (cross-device), fall through to deletion
}
}
else {
// Delete working state
try {
rmSync(loopDir, { recursive: true, force: true });
}
catch {
// Non-fatal
}
}
}
// Remove entry from launcher registry
const registry = loadLauncherRegistry(projectRoot);
if (registry.loops[loopId]) {
delete registry.loops[loopId];
saveLauncherRegistry(projectRoot, registry);
}
return {
success: true,
message: `Cleaned up loop ${loopId}`,
preserved,
};
}
/**
* Clean up internal Ralph state after successful completion.
* Deletes current-loop.json, heartbeats, and iteration working state.
* Preserves completion report files.
*
* @param projectRoot - Project root directory
* @returns Object with success status and what was cleaned
*/
export function cleanupInternalRalph(projectRoot) {
const ralphDir = join(projectRoot, '.aiwg', 'ralph');
const cleaned = [];
const preserved = [];
if (!existsSync(ralphDir)) {
return { success: true, cleaned: [], preserved: [] };
}
// Check if current-loop.json exists and is completed
const currentLoopPath = join(ralphDir, 'current-loop.json');
if (existsSync(currentLoopPath)) {
try {
const state = JSON.parse(readFileSync(currentLoopPath, 'utf8'));
if (state.status === 'completed' || state.status === 'success') {
rmSync(currentLoopPath, { force: true });
cleaned.push('current-loop.json');
}
}
catch {
// If we can't parse it, leave it alone
}
}
// Clean up heartbeats directory
const heartbeatsDir = join(ralphDir, 'heartbeats');
if (existsSync(heartbeatsDir)) {
try {
rmSync(heartbeatsDir, { recursive: true, force: true });
cleaned.push('heartbeats/');
}
catch {
// Non-fatal
}
}
// Clean up iterations working state (but preserve completion reports)
const iterationsDir = join(ralphDir, 'iterations');
if (existsSync(iterationsDir)) {
try {
rmSync(iterationsDir, { recursive: true, force: true });
cleaned.push('iterations/');
}
catch {
// Non-fatal
}
}
// Log preserved completion reports
try {
const files = readdirSync(ralphDir);
for (const file of files) {
if (file.startsWith('completion-') && file.endsWith('.md')) {
preserved.push(file);
}
}
}
catch {
// Non-fatal
}
return { success: true, cleaned, preserved };
}
/**
* Clean up completed/failed loops from registry
*/
export function cleanupRegistry(projectRoot, keepDays = 7) {
const registry = loadLauncherRegistry(projectRoot);
const cutoff = Date.now() - keepDays * 24 * 60 * 60 * 1000;
let cleaned = 0;
for (const [loopId, entry] of Object.entries(registry.loops)) {
if (entry.status !== 'running') {
const lastUpdate = new Date(entry.lastUpdate).getTime();
if (lastUpdate < cutoff) {
delete registry.loops[loopId];
cleaned++;
}
}
}
if (cleaned > 0) {
saveLauncherRegistry(projectRoot, registry);
}
return cleaned;
}
/**
* Attach to a running agent loop's output stream.
*
* Tails the loop's daemon-output.log to stdout in real-time.
* Ctrl+C detaches (the background loop keeps running).
* Returns a Promise that resolves when the user detaches or the loop exits.
*/
export function attachToLoopOutput(projectRoot, loopId) {
const registry = loadLauncherRegistry(projectRoot);
const entries = Object.values(registry.loops);
let entry;
if (loopId) {
entry = registry.loops[loopId];
if (!entry) {
return Promise.reject(new Error(`Loop not found: ${loopId}`));
}
}
else {
// Use most recently started running loop
const running = entries
.filter((e) => e.status === 'running' && isProcessAlive(e.pid))
.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
if (running.length === 0) {
return Promise.reject(new Error('No running loops to attach to. Use --loop-id to specify a loop.'));
}
entry = running[0];
}
const outputFile = entry.outputFile;
const attachedLoopId = entry.loopId;
return new Promise((resolve) => {
let offset = 0;
// Print any existing content
try {
const stats = statSync(outputFile);
if (stats.size > 0) {
const fd = openSync(outputFile, 'r');
const buf = Buffer.alloc(stats.size);
readSync(fd, buf, 0, stats.size, 0);
closeSync(fd);
process.stdout.write(buf);
offset = stats.size;
}
}
catch {
// Log file may not exist yet — will appear shortly
}
// Poll for new content every 250 ms. .unref() so the interval cannot
// outlive the resolve() path and hold the event loop alive.
const interval = setInterval(() => {
try {
const stats = statSync(outputFile);
if (stats.size > offset) {
const newBytes = stats.size - offset;
const fd = openSync(outputFile, 'r');
const buf = Buffer.alloc(newBytes);
readSync(fd, buf, 0, newBytes, offset);
closeSync(fd);
process.stdout.write(buf);
offset = stats.size;
}
// Stop polling if the process has exited
if (!isProcessAlive(registry.loops[attachedLoopId]?.pid ?? 0)) {
clearInterval(interval);
process.removeListener('SIGINT', onSigint);
process.stdout.write('\n[ralph-attach] Loop process has exited.\n');
resolve();
}
}
catch {
// File may be temporarily unavailable during rotation — ignore
}
}, 250);
interval.unref?.();
// Ctrl+C detaches without stopping the loop. Registered with named handler
// + process.once so we can remove it cleanly on process-exit path and so
// it does not accumulate across re-attach cycles within a single CLI run.
const onSigint = () => {
clearInterval(interval);
process.stdout.write('\n\nDetached from loop output. Loop continues in background.\n');
process.stdout.write(` Status: aiwg ralph-status\n`);
process.stdout.write(` Re-attach: aiwg ralph-attach --loop-id ${attachedLoopId}\n`);
process.stdout.write(` Abort: aiwg ralph-abort --loop-id ${attachedLoopId}\n`);
resolve();
};
process.once('SIGINT', onSigint);
});
}
//# sourceMappingURL=ralph-launcher.js.map