feat: implement Claudeman - Claude session manager with Ralph Loop

Claudeman manages multiple Claude CLI sessions as subprocesses with:
- Session management (start/stop/list/logs)
- Priority-based task queue with dependency support
- Ralph Loop for autonomous task assignment and completion detection
- Time-aware loops for extended work sessions
- JSON file persistence to ~/.claudeman/state.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-18 04:50:46 +01:00
co-authored by Claude Opus 4.5
commit e1bb800473
16 changed files with 2830 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
import { EventEmitter } from 'node:events';
import { getSessionManager, SessionManager } from './session-manager.js';
import { getTaskQueue, TaskQueue } from './task-queue.js';
import { getStore, StateStore } from './state-store.js';
import { Session } from './session.js';
import { Task } from './task.js';
import { RalphLoopStatus } from './types.js';
export interface RalphLoopEvents {
started: () => void;
stopped: () => void;
taskAssigned: (taskId: string, sessionId: string) => void;
taskCompleted: (taskId: string) => void;
taskFailed: (taskId: string, error: string) => void;
error: (error: Error) => void;
}
export interface RalphLoopOptions {
pollIntervalMs?: number;
minDurationMs?: number;
autoGenerateTasks?: boolean;
}
export class RalphLoop extends EventEmitter {
private sessionManager: SessionManager;
private taskQueue: TaskQueue;
private store: StateStore;
private pollIntervalMs: number;
private minDurationMs: number | null;
private autoGenerateTasks: boolean;
private loopTimer: NodeJS.Timeout | null = null;
private _status: RalphLoopStatus = 'stopped';
private startedAt: number | null = null;
private tasksCompleted: number = 0;
private tasksGenerated: number = 0;
constructor(options: RalphLoopOptions = {}) {
super();
this.sessionManager = getSessionManager();
this.taskQueue = getTaskQueue();
this.store = getStore();
const config = this.store.getConfig();
this.pollIntervalMs = options.pollIntervalMs ?? config.pollIntervalMs;
this.minDurationMs = options.minDurationMs ?? null;
this.autoGenerateTasks = options.autoGenerateTasks ?? true;
// Load state from store
const savedState = this.store.getRalphLoopState();
if (savedState.status === 'running') {
// If we crashed while running, reset to stopped
this._status = 'stopped';
this.store.setRalphLoopState({ status: 'stopped' });
}
this.setupEventHandlers();
}
private setupEventHandlers(): void {
this.sessionManager.on('sessionCompletion', (sessionId: string, phrase: string) => {
this.handleSessionCompletion(sessionId, phrase);
});
this.sessionManager.on('sessionError', (sessionId: string, error: string) => {
this.handleSessionError(sessionId, error);
});
this.sessionManager.on('sessionStopped', (sessionId: string) => {
this.handleSessionStopped(sessionId);
});
}
get status(): RalphLoopStatus {
return this._status;
}
isRunning(): boolean {
return this._status === 'running';
}
getElapsedMs(): number {
if (!this.startedAt) {
return 0;
}
return Date.now() - this.startedAt;
}
getElapsedHours(): number {
return this.getElapsedMs() / (1000 * 60 * 60);
}
isMinDurationReached(): boolean {
if (!this.minDurationMs) {
return true;
}
return this.getElapsedMs() >= this.minDurationMs;
}
getStats() {
const taskCounts = this.taskQueue.getCount();
return {
status: this._status,
elapsedMs: this.getElapsedMs(),
elapsedHours: this.getElapsedHours(),
minDurationMs: this.minDurationMs,
minDurationReached: this.isMinDurationReached(),
tasksCompleted: this.tasksCompleted,
tasksGenerated: this.tasksGenerated,
...taskCounts,
activeSessions: this.sessionManager.getSessionCount(),
idleSessions: this.sessionManager.getIdleSessions().length,
busySessions: this.sessionManager.getBusySessions().length,
};
}
async start(): Promise<void> {
if (this._status === 'running') {
return;
}
this._status = 'running';
this.startedAt = Date.now();
this.tasksCompleted = 0;
this.tasksGenerated = 0;
this.store.setRalphLoopState({
status: 'running',
startedAt: this.startedAt,
minDurationMs: this.minDurationMs,
tasksCompleted: 0,
tasksGenerated: 0,
});
this.emit('started');
this.runLoop();
}
stop(): void {
if (this._status === 'stopped') {
return;
}
this._status = 'stopped';
if (this.loopTimer) {
clearTimeout(this.loopTimer);
this.loopTimer = null;
}
this.store.setRalphLoopState({
status: 'stopped',
lastCheckAt: Date.now(),
});
this.emit('stopped');
}
pause(): void {
if (this._status !== 'running') {
return;
}
this._status = 'paused';
if (this.loopTimer) {
clearTimeout(this.loopTimer);
this.loopTimer = null;
}
this.store.setRalphLoopState({ status: 'paused' });
}
resume(): void {
if (this._status !== 'paused') {
return;
}
this._status = 'running';
this.store.setRalphLoopState({ status: 'running' });
this.runLoop();
}
private runLoop(): void {
if (this._status !== 'running') {
return;
}
this.tick()
.catch((err) => {
this.emit('error', err);
})
.finally(() => {
if (this._status === 'running') {
this.loopTimer = setTimeout(() => this.runLoop(), this.pollIntervalMs);
}
});
}
private async tick(): Promise<void> {
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
// Check for timed out tasks
await this.checkTimeouts();
// Assign tasks to idle sessions
await this.assignTasks();
// Check if we should auto-generate tasks
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
await this.generateFollowUpTasks();
}
// Check if we're done
if (this.shouldStop()) {
this.stop();
}
}
private async assignTasks(): Promise<void> {
const idleSessions = this.sessionManager.getIdleSessions();
for (const session of idleSessions) {
const task = this.taskQueue.next();
if (!task) {
break;
}
await this.assignTaskToSession(task, session);
}
}
private async assignTaskToSession(task: Task, session: Session): Promise<void> {
try {
task.assign(session.id);
session.assignTask(task.id);
this.taskQueue.updateTask(task);
// Send the prompt to the session
await session.sendInput(task.prompt);
this.emit('taskAssigned', task.id, session.id);
} catch (err) {
task.fail((err as Error).message);
session.clearTask();
this.taskQueue.updateTask(task);
this.emit('taskFailed', task.id, (err as Error).message);
}
}
private async checkTimeouts(): Promise<void> {
for (const task of this.taskQueue.getRunningTasks()) {
if (task.isTimedOut()) {
task.fail('Task timed out');
this.taskQueue.updateTask(task);
const session = this.sessionManager.getSession(task.assignedSessionId!);
if (session) {
session.clearTask();
}
this.emit('taskFailed', task.id, 'Task timed out');
}
}
}
private handleSessionCompletion(sessionId: string, phrase: string): void {
const session = this.sessionManager.getSession(sessionId);
if (!session) {
return;
}
const taskId = session.currentTaskId;
if (!taskId) {
return;
}
const task = this.taskQueue.getTask(taskId);
if (!task) {
return;
}
// Append output and check for completion
task.appendOutput(session.getOutput());
if (task.checkCompletion(session.getOutput()) || phrase) {
task.complete();
this.taskQueue.updateTask(task);
session.clearTask();
this.tasksCompleted++;
this.store.setRalphLoopState({ tasksCompleted: this.tasksCompleted });
this.emit('taskCompleted', task.id);
}
}
private handleSessionError(sessionId: string, error: string): void {
const session = this.sessionManager.getSession(sessionId);
if (!session) {
return;
}
const taskId = session.currentTaskId;
if (!taskId) {
return;
}
const task = this.taskQueue.getTask(taskId);
if (!task) {
return;
}
task.setError(error);
// Don't fail the task immediately on stderr - some tools write to stderr normally
}
private handleSessionStopped(sessionId: string): void {
const task = this.taskQueue.getRunningTaskForSession(sessionId);
if (task) {
task.fail('Session stopped unexpectedly');
this.taskQueue.updateTask(task);
this.emit('taskFailed', task.id, 'Session stopped unexpectedly');
}
}
private shouldGenerateTasks(): boolean {
// Generate tasks if:
// 1. No pending tasks
// 2. Min duration not reached
// 3. We have idle sessions
const counts = this.taskQueue.getCount();
return (
counts.pending === 0 &&
!this.isMinDurationReached() &&
this.sessionManager.getIdleSessions().length > 0
);
}
private async generateFollowUpTasks(): Promise<void> {
// This is a placeholder for auto-generating follow-up tasks
// In a real implementation, this could:
// - Analyze completed tasks to find optimization opportunities
// - Generate tasks for code cleanup, tests, documentation
// - Use Claude to suggest improvements
const suggestions = [
'Review and optimize recently changed code',
'Add tests for uncovered code paths',
'Update documentation for changed APIs',
'Check for security vulnerabilities',
'Run linting and fix any issues',
];
// Only generate one task at a time
const suggestion = suggestions[this.tasksGenerated % suggestions.length];
const defaultDir = process.cwd();
this.taskQueue.addTask({
prompt: suggestion,
workingDir: defaultDir,
priority: -1, // Lower priority than user-added tasks
});
this.tasksGenerated++;
this.store.setRalphLoopState({ tasksGenerated: this.tasksGenerated });
}
private shouldStop(): boolean {
const counts = this.taskQueue.getCount();
// Don't stop if there are pending or running tasks
if (counts.pending > 0 || counts.running > 0) {
return false;
}
// Don't stop if min duration not reached and auto-generate is on
if (!this.isMinDurationReached() && this.autoGenerateTasks) {
return false;
}
// All tasks done and conditions met
return true;
}
setMinDuration(hours: number): void {
this.minDurationMs = hours * 60 * 60 * 1000;
this.store.setRalphLoopState({ minDurationMs: this.minDurationMs });
}
}
// Singleton instance
let loopInstance: RalphLoop | null = null;
export function getRalphLoop(options?: RalphLoopOptions): RalphLoop {
if (!loopInstance) {
loopInstance = new RalphLoop(options);
}
return loopInstance;
}