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
311 lines (272 loc) • 10.8 kB
text/typescript
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import fs from 'fs-extra';
import path from 'node:path';
export interface LoopRunOptions {
name?: string;
projectPath?: string;
prompt?: string;
command?: string;
codexCommand?: string;
model?: string;
intervalSeconds?: number;
maxIterations?: number;
timeoutMs?: number;
untilFile?: string;
stateDir?: string;
sandbox?: string;
skipGitRepoCheck?: boolean;
dryRun?: boolean;
onEvent?: (event: LoopEvent) => void;
}
export interface LoopState {
name: string;
projectPath: string;
mode: 'codex' | 'command';
prompt?: string;
command?: string;
status: 'idle' | 'running' | 'stopping' | 'completed' | 'failed' | 'stopped';
iteration: number;
maxIterations: number;
intervalSeconds: number;
startedAt: string;
updatedAt: string;
lastExitCode?: number | null;
lastOutput?: string;
lastError?: string;
untilFile: string;
}
export interface LoopEvent {
type: 'start' | 'iteration-start' | 'iteration-complete' | 'sleep' | 'stop' | 'complete' | 'error' | 'dry-run';
state: LoopState;
message?: string;
}
export interface LoopPaths {
stateDir: string;
statePath: string;
stopPath: string;
completePath: string;
}
export interface LoopCommandResult {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}
export function normalizeLoopName(name = 'default'): string {
const normalized = name.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
return normalized || 'default';
}
export function resolveLoopPaths(projectPath: string, name = 'default', stateDir?: string): LoopPaths {
const safeName = normalizeLoopName(name);
const resolvedStateDir = path.resolve(projectPath, stateDir ?? path.join('.codex', 'loop'));
return {
stateDir: resolvedStateDir,
statePath: path.join(resolvedStateDir, `${safeName}.json`),
stopPath: path.join(resolvedStateDir, `${safeName}.stop`),
completePath: path.join(resolvedStateDir, `${safeName}.complete`),
};
}
export async function loadLoopState(projectPath: string, name = 'default', stateDir?: string): Promise<LoopState | null> {
const paths = resolveLoopPaths(projectPath, name, stateDir);
if (!await fs.pathExists(paths.statePath)) return null;
return await fs.readJson(paths.statePath) as LoopState;
}
export async function requestLoopStop(projectPath: string, name = 'default', stateDir?: string): Promise<LoopPaths> {
const paths = resolveLoopPaths(projectPath, name, stateDir);
await fs.ensureDir(paths.stateDir);
await fs.writeFile(paths.stopPath, new Date().toISOString());
const state = await loadLoopState(projectPath, name, stateDir);
if (state && state.status === 'running') {
state.status = 'stopping';
state.updatedAt = new Date().toISOString();
await saveLoopState(paths.statePath, state);
}
return paths;
}
export function buildCodexLoopPrompt(state: LoopState): string {
const max = state.maxIterations > 0 ? state.maxIterations : 'unbounded';
return [
'You are running inside a Codex /loop-compatible iteration.',
`Loop: ${state.name}`,
`Iteration: ${state.iteration}/${max}`,
`Project: ${state.projectPath}`,
'',
'Task:',
state.prompt ?? '',
'',
'Work autonomously for this iteration. Make concrete progress, run relevant checks, and stop before broad unrelated refactors.',
`If the task is fully complete, create this marker file: ${state.untilFile}`,
'If more work remains, leave the marker absent so the next loop iteration can continue.',
].join('\n');
}
export async function runCodexLoop(options: LoopRunOptions = {}): Promise<LoopState> {
const projectPath = path.resolve(options.projectPath ?? process.cwd());
const name = normalizeLoopName(options.name);
const paths = resolveLoopPaths(projectPath, name, options.stateDir);
const intervalSeconds = clampInteger(options.intervalSeconds ?? 270, 0, 86_400);
const maxIterations = clampInteger(options.maxIterations ?? 10, 0, 100_000);
const timeoutMs = clampInteger(options.timeoutMs ?? 30 * 60_000, 1_000, 24 * 60 * 60_000);
const untilFile = path.resolve(projectPath, options.untilFile ?? paths.completePath);
const mode: LoopState['mode'] = options.command ? 'command' : 'codex';
if (mode === 'codex' && !options.prompt?.trim()) {
throw new Error('loop run requires a prompt unless --command is provided');
}
await fs.ensureDir(paths.stateDir);
await fs.remove(paths.stopPath);
const startedAt = new Date().toISOString();
const state: LoopState = {
name,
projectPath,
mode,
status: 'running',
iteration: 0,
maxIterations,
intervalSeconds,
startedAt,
updatedAt: startedAt,
untilFile,
};
if (options.prompt !== undefined) state.prompt = options.prompt;
if (options.command !== undefined) state.command = options.command;
await saveLoopState(paths.statePath, state);
emit(options, 'start', state, `Loop ${name} started`);
if (options.dryRun) {
state.status = 'idle';
state.updatedAt = new Date().toISOString();
await saveLoopState(paths.statePath, state);
emit(options, 'dry-run', state, 'Dry run complete');
return state;
}
try {
while (shouldContinue(state, paths)) {
state.iteration += 1;
state.updatedAt = new Date().toISOString();
await saveLoopState(paths.statePath, state);
emit(options, 'iteration-start', state, `Iteration ${state.iteration} starting`);
const result = mode === 'command'
? await runShellCommand(options.command!, projectPath, timeoutMs)
: await runCodexExec(options, state, timeoutMs);
state.lastExitCode = result.code;
state.lastOutput = truncate(result.stdout || result.stderr, 8_000);
if (result.code === 0 || result.code === null) {
delete state.lastError;
} else {
state.lastError = truncate(result.stderr || result.stdout, 8_000);
}
state.updatedAt = new Date().toISOString();
await saveLoopState(paths.statePath, state);
emit(options, 'iteration-complete', state, `Iteration ${state.iteration} exited with ${result.code ?? result.signal ?? 'unknown'}`);
if (existsSync(untilFile)) {
state.status = 'completed';
state.updatedAt = new Date().toISOString();
await saveLoopState(paths.statePath, state);
emit(options, 'complete', state, `Completion marker found: ${untilFile}`);
return state;
}
if (result.code !== 0 && result.code !== null) {
state.status = 'failed';
state.updatedAt = new Date().toISOString();
await saveLoopState(paths.statePath, state);
emit(options, 'error', state, `Iteration failed with exit code ${result.code}`);
return state;
}
if (!shouldContinue(state, paths)) break;
emit(options, 'sleep', state, `Sleeping ${intervalSeconds}s`);
await sleep(intervalSeconds * 1000);
}
state.status = existsSync(paths.stopPath) ? 'stopped' : 'completed';
state.updatedAt = new Date().toISOString();
await saveLoopState(paths.statePath, state);
emit(options, state.status === 'stopped' ? 'stop' : 'complete', state);
return state;
} catch (error) {
state.status = 'failed';
state.lastError = error instanceof Error ? error.message : String(error);
state.updatedAt = new Date().toISOString();
await saveLoopState(paths.statePath, state);
emit(options, 'error', state, state.lastError);
return state;
}
}
async function runCodexExec(options: LoopRunOptions, state: LoopState, timeoutMs: number): Promise<LoopCommandResult> {
const args = ['exec', '--sandbox', options.sandbox ?? 'workspace-write'];
if (options.skipGitRepoCheck !== false) args.push('--skip-git-repo-check');
if (options.model) args.push('-m', options.model);
args.push(buildCodexLoopPrompt(state));
return runProcess(options.codexCommand ?? 'codex', args, state.projectPath, timeoutMs);
}
async function runShellCommand(command: string, cwd: string, timeoutMs: number): Promise<LoopCommandResult> {
return new Promise((resolve, reject) => {
const child = spawn(command, {
cwd,
shell: true,
env: { ...process.env, FORCE_COLOR: '0' },
stdio: ['ignore', 'pipe', 'pipe'],
});
collectChild(child, timeoutMs, resolve, reject);
});
}
async function runProcess(command: string, args: string[], cwd: string, timeoutMs: number): Promise<LoopCommandResult> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: { ...process.env, FORCE_COLOR: '0' },
stdio: ['ignore', 'pipe', 'pipe'],
});
collectChild(child, timeoutMs, resolve, reject);
});
}
function collectChild(
child: ReturnType<typeof spawn>,
timeoutMs: number,
resolve: (result: LoopCommandResult) => void,
reject: (error: Error) => void
): void {
let stdout = '';
let stderr = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, timeoutMs);
child.stdout?.on('data', data => { stdout += data.toString(); });
child.stderr?.on('data', data => { stderr += data.toString(); });
child.on('error', err => {
clearTimeout(timer);
reject(err);
});
child.on('close', (code, signal) => {
clearTimeout(timer);
if (timedOut) {
resolve({ code: 124, signal, stdout, stderr: stderr || `Timed out after ${timeoutMs}ms` });
return;
}
resolve({ code, signal, stdout, stderr });
});
}
async function saveLoopState(statePath: string, state: LoopState): Promise<void> {
await fs.ensureDir(path.dirname(statePath));
await fs.writeJson(statePath, state, { spaces: 2 });
}
function shouldContinue(state: LoopState, paths: LoopPaths): boolean {
if (state.status !== 'running') return false;
if (existsSync(paths.stopPath)) return false;
if (existsSync(state.untilFile)) return false;
return state.maxIterations === 0 || state.iteration < state.maxIterations;
}
function emit(options: LoopRunOptions, type: LoopEvent['type'], state: LoopState, message?: string): void {
const event: LoopEvent = { type, state: { ...state } };
if (message !== undefined) event.message = message;
options.onEvent?.(event);
}
function clampInteger(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.floor(value)));
}
function truncate(value: string, max: number): string {
return value.length <= max ? value : `${value.slice(0, max)}\n...[truncated]`;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}