mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
refactor: rename inner-loop-tracker to ralph-tracker with API improvements
- Rename inner-loop-tracker.ts → ralph-tracker.ts throughout codebase - Add ralph-config.ts for parsing .claude/ralph-loop.local.md config - Standardize API error responses using createErrorResponse() - Add input validation for auto-compact/auto-clear thresholds - Update UI labels to "Ralph / Todo Tracker" consistently - Add 46 integration tests for Ralph tracking functionality - Update test badge to 438 total tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @fileoverview Ralph Wiggum configuration parser
|
||||
*
|
||||
* Parses the official Ralph Wiggum plugin state file (.claude/ralph-loop.local.md)
|
||||
* and extracts configuration for session tracking.
|
||||
*
|
||||
* @module ralph-config
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Pattern to extract completion phrase from CLAUDE.md
|
||||
// Matches <promise>PHRASE</promise> with optional whitespace
|
||||
// Allows: letters, numbers, underscores, hyphens (e.g., TESTS-PASS, ALL_DONE)
|
||||
const CLAUDE_MD_PROMISE_PATTERN = /<promise>\s*([A-Z0-9_-]+)\s*<\/promise>/gi;
|
||||
|
||||
// Pattern to parse YAML frontmatter from ralph-loop.local.md
|
||||
// Extracts key: value pairs from between --- markers
|
||||
const YAML_FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---/;
|
||||
const YAML_LINE_PATTERN = /^([a-z-]+):\s*"?([^"\n]+)"?\s*$/gm;
|
||||
|
||||
/**
|
||||
* Ralph Loop configuration from .claude/ralph-loop.local.md
|
||||
*/
|
||||
export interface RalphLoopConfig {
|
||||
enabled: boolean;
|
||||
iteration: number;
|
||||
maxIterations: number | null;
|
||||
completionPromise: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ralph Wiggum loop config from .claude/ralph-loop.local.md
|
||||
*
|
||||
* The official Ralph Wiggum plugin stores state in this file with YAML frontmatter:
|
||||
* ```yaml
|
||||
* ---
|
||||
* enabled: true
|
||||
* iteration: 5
|
||||
* max-iterations: 50
|
||||
* completion-promise: "COMPLETE"
|
||||
* ---
|
||||
* # Original Prompt
|
||||
* ...
|
||||
* ```
|
||||
*
|
||||
* @param workingDir - Working directory to search in
|
||||
* @returns Parsed config or null if file doesn't exist or is invalid
|
||||
*/
|
||||
export function parseRalphLoopConfig(workingDir: string): RalphLoopConfig | null {
|
||||
const configPath = join(workingDir, '.claude', 'ralph-loop.local.md');
|
||||
|
||||
try {
|
||||
if (!existsSync(configPath)) return null;
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
|
||||
return parseRalphLoopConfigFromContent(content);
|
||||
} catch (err) {
|
||||
console.error(`[ralph-config] Error reading ralph-loop.local.md: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ralph Wiggum loop config from file content.
|
||||
* Exported for testing.
|
||||
*
|
||||
* @param content - File content to parse
|
||||
* @returns Parsed config or null if content is invalid
|
||||
*/
|
||||
export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfig | null {
|
||||
// Extract YAML frontmatter
|
||||
const frontmatterMatch = content.match(YAML_FRONTMATTER_PATTERN);
|
||||
if (!frontmatterMatch) return null;
|
||||
|
||||
const yaml = frontmatterMatch[1];
|
||||
const config: RalphLoopConfig = {
|
||||
enabled: false,
|
||||
iteration: 0,
|
||||
maxIterations: null,
|
||||
completionPromise: null,
|
||||
};
|
||||
|
||||
// Parse each YAML line
|
||||
let match;
|
||||
YAML_LINE_PATTERN.lastIndex = 0;
|
||||
while ((match = YAML_LINE_PATTERN.exec(yaml)) !== null) {
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2].trim();
|
||||
|
||||
switch (key) {
|
||||
case 'enabled':
|
||||
config.enabled = value === 'true';
|
||||
break;
|
||||
case 'iteration':
|
||||
config.iteration = parseInt(value, 10) || 0;
|
||||
break;
|
||||
case 'max-iterations':
|
||||
config.maxIterations = parseInt(value, 10) || null;
|
||||
break;
|
||||
case 'completion-promise':
|
||||
config.completionPromise = value.toUpperCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract completion phrase from CLAUDE.md content.
|
||||
* Looks for <promise>PHRASE</promise> pattern.
|
||||
*
|
||||
* Handles multiple variations:
|
||||
* - Raw text: <promise>PHRASE</promise>
|
||||
* - In backticks: `<promise>PHRASE</promise>`
|
||||
* - With whitespace: <promise> PHRASE </promise>
|
||||
* - Multiple occurrences: returns the first one
|
||||
*
|
||||
* @param claudeMdPath - Path to CLAUDE.md file
|
||||
* @returns The completion phrase (uppercase), or null if not found
|
||||
*/
|
||||
export function extractCompletionPhrase(claudeMdPath: string): string | null {
|
||||
try {
|
||||
if (!existsSync(claudeMdPath)) return null;
|
||||
const content = readFileSync(claudeMdPath, 'utf-8');
|
||||
|
||||
return extractCompletionPhraseFromContent(content);
|
||||
} catch (err) {
|
||||
console.error(`[ralph-config] Error reading CLAUDE.md: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract completion phrase from CLAUDE.md content string.
|
||||
* Exported for testing.
|
||||
*
|
||||
* @param content - CLAUDE.md content to parse
|
||||
* @returns The completion phrase (uppercase), or null if not found
|
||||
*/
|
||||
export function extractCompletionPhraseFromContent(content: string): string | null {
|
||||
// Reset regex state (global flag)
|
||||
CLAUDE_MD_PROMISE_PATTERN.lastIndex = 0;
|
||||
|
||||
// Find all matches and return the first one
|
||||
const match = CLAUDE_MD_PROMISE_PATTERN.exec(content);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim().toUpperCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Inner Loop Tracker for Ralph Wiggum detection
|
||||
* @fileoverview Ralph Tracker - Detects Ralph Wiggum loops, todos, and completion phrases
|
||||
*
|
||||
* This module parses terminal output from Claude Code sessions to detect:
|
||||
* - Ralph Wiggum loop state (active, completion phrase, iteration count)
|
||||
@@ -10,15 +10,15 @@
|
||||
* patterns are detected in the output stream, reducing overhead for
|
||||
* sessions not using autonomous loops.
|
||||
*
|
||||
* @module inner-loop-tracker
|
||||
* @module ralph-tracker
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import {
|
||||
InnerLoopState,
|
||||
InnerTodoItem,
|
||||
InnerTodoStatus,
|
||||
createInitialInnerLoopState,
|
||||
RalphTrackerState,
|
||||
RalphTodoItem,
|
||||
RalphTodoStatus,
|
||||
createInitialRalphTrackerState,
|
||||
} from './types.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
@@ -56,6 +56,12 @@ const EVENT_DEBOUNCE_MS = 50;
|
||||
* Matches completion phrase tags: `<promise>PHRASE</promise>`
|
||||
* Used to detect when Claude signals task completion.
|
||||
* Capture group 1: The completion phrase text
|
||||
*
|
||||
* Supports any characters between tags including:
|
||||
* - Uppercase letters: COMPLETE, DONE
|
||||
* - Numbers: TASK_123
|
||||
* - Underscores: ALL_TASKS_DONE
|
||||
* - Hyphens: TESTS-PASS, TIME-COMPLETE
|
||||
*/
|
||||
const PROMISE_PATTERN = /<promise>([^<]+)<\/promise>/;
|
||||
|
||||
@@ -194,17 +200,17 @@ const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||
// ========== Event Types ==========
|
||||
|
||||
/**
|
||||
* Events emitted by InnerLoopTracker
|
||||
* Events emitted by RalphTracker
|
||||
* @event loopUpdate - Fired when loop state changes (active, iteration, completion phrase)
|
||||
* @event todoUpdate - Fired when todo list changes (items added, status changed)
|
||||
* @event completionDetected - Fired when completion phrase is detected (task complete)
|
||||
* @event enabled - Fired when tracker auto-enables due to Ralph pattern detection
|
||||
*/
|
||||
export interface InnerLoopTrackerEvents {
|
||||
export interface RalphTrackerEvents {
|
||||
/** Emitted when loop state changes */
|
||||
loopUpdate: (state: InnerLoopState) => void;
|
||||
loopUpdate: (state: RalphTrackerState) => void;
|
||||
/** Emitted when todo list is modified */
|
||||
todoUpdate: (todos: InnerTodoItem[]) => void;
|
||||
todoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
/** Emitted when completion phrase detected (loop finished) */
|
||||
completionDetected: (phrase: string) => void;
|
||||
/** Emitted when tracker auto-enables from disabled state */
|
||||
@@ -212,7 +218,7 @@ export interface InnerLoopTrackerEvents {
|
||||
}
|
||||
|
||||
/**
|
||||
* InnerLoopTracker - Parses terminal output to detect Ralph Wiggum loops and todos
|
||||
* RalphTracker - Parses terminal output to detect Ralph Wiggum loops and todos
|
||||
*
|
||||
* This class monitors Claude Code session output to detect:
|
||||
* 1. **Ralph Wiggum loop state** - Active loops, completion phrases, iteration counts
|
||||
@@ -242,19 +248,19 @@ export interface InnerLoopTrackerEvents {
|
||||
* @extends EventEmitter
|
||||
* @example
|
||||
* ```typescript
|
||||
* const tracker = new InnerLoopTracker();
|
||||
* const tracker = new RalphTracker();
|
||||
* tracker.on('completionDetected', (phrase) => {
|
||||
* console.log('Loop completed with phrase:', phrase);
|
||||
* });
|
||||
* tracker.processTerminalData(ptyOutput);
|
||||
* ```
|
||||
*/
|
||||
export class InnerLoopTracker extends EventEmitter {
|
||||
export class RalphTracker extends EventEmitter {
|
||||
/** Current state of the detected loop */
|
||||
private _loopState: InnerLoopState;
|
||||
private _loopState: RalphTrackerState;
|
||||
|
||||
/** Map of todo items by ID for O(1) lookup */
|
||||
private _todos: Map<string, InnerTodoItem> = new Map();
|
||||
private _todos: Map<string, RalphTodoItem> = new Map();
|
||||
|
||||
/** Buffer for incomplete lines from terminal data */
|
||||
private _lineBuffer: string = '';
|
||||
@@ -284,12 +290,12 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
private _autoEnableDisabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Creates a new InnerLoopTracker instance.
|
||||
* Creates a new RalphTracker instance.
|
||||
* Starts in disabled state until Ralph patterns are detected.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
this._loopState = createInitialInnerLoopState();
|
||||
this._loopState = createInitialRalphTrackerState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,7 +379,7 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
this.clearDebounceTimers();
|
||||
|
||||
const wasEnabled = this._loopState.enabled;
|
||||
this._loopState = createInitialInnerLoopState();
|
||||
this._loopState = createInitialRalphTrackerState();
|
||||
this._loopState.enabled = wasEnabled; // Keep enabled status
|
||||
this._todos.clear();
|
||||
this._completionPhraseCount.clear();
|
||||
@@ -394,7 +400,7 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
// Clear debounce timers
|
||||
this.clearDebounceTimers();
|
||||
|
||||
this._loopState = createInitialInnerLoopState();
|
||||
this._loopState = createInitialRalphTrackerState();
|
||||
this._todos.clear();
|
||||
this._completionPhraseCount.clear();
|
||||
this._lineBuffer = '';
|
||||
@@ -489,7 +495,7 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
* Get a copy of the current loop state.
|
||||
* @returns Shallow copy of loop state (safe to modify)
|
||||
*/
|
||||
get loopState(): InnerLoopState {
|
||||
get loopState(): RalphTrackerState {
|
||||
return { ...this._loopState };
|
||||
}
|
||||
|
||||
@@ -497,7 +503,7 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
* Get all tracked todo items as an array.
|
||||
* @returns Array of todo items (copy, safe to modify)
|
||||
*/
|
||||
get todos(): InnerTodoItem[] {
|
||||
get todos(): RalphTodoItem[] {
|
||||
return Array.from(this._todos.values());
|
||||
}
|
||||
|
||||
@@ -1032,7 +1038,7 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) {
|
||||
const checked = match[1].toLowerCase() === 'x';
|
||||
const content = match[2].trim();
|
||||
const status: InnerTodoStatus = checked ? 'completed' : 'pending';
|
||||
const status: RalphTodoStatus = checked ? 'completed' : 'pending';
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
@@ -1057,7 +1063,7 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
TODO_STATUS_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) {
|
||||
const content = match[1].trim();
|
||||
const status = match[2] as InnerTodoStatus;
|
||||
const status = match[2] as RalphTodoStatus;
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
@@ -1099,9 +1105,9 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
* - Pending: `☐`, `○`, and anything else (default)
|
||||
*
|
||||
* @param icon - Single character icon
|
||||
* @returns Corresponding InnerTodoStatus
|
||||
* @returns Corresponding RalphTodoStatus
|
||||
*/
|
||||
private iconToStatus(icon: string): InnerTodoStatus {
|
||||
private iconToStatus(icon: string): RalphTodoStatus {
|
||||
switch (icon) {
|
||||
case '✓':
|
||||
case '✅':
|
||||
@@ -1134,7 +1140,7 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
* @param content - Raw todo content text
|
||||
* @param status - Status to set
|
||||
*/
|
||||
private upsertTodo(content: string, status: InnerTodoStatus): void {
|
||||
private upsertTodo(content: string, status: RalphTodoStatus): void {
|
||||
// Skip empty or whitespace-only content
|
||||
if (!content || !content.trim()) return;
|
||||
|
||||
@@ -1225,8 +1231,8 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
* Used for LRU eviction when at MAX_TODO_ITEMS limit.
|
||||
* @returns Oldest todo item, or undefined if map is empty
|
||||
*/
|
||||
private findOldestTodo(): InnerTodoItem | undefined {
|
||||
let oldest: InnerTodoItem | undefined;
|
||||
private findOldestTodo(): RalphTodoItem | undefined {
|
||||
let oldest: RalphTodoItem | undefined;
|
||||
for (const todo of this._todos.values()) {
|
||||
if (!oldest || todo.detectedAt < oldest.detectedAt) {
|
||||
oldest = todo;
|
||||
@@ -1334,7 +1340,7 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
* @fires todoUpdate - With empty array
|
||||
*/
|
||||
clear(): void {
|
||||
this._loopState = createInitialInnerLoopState(); // This sets enabled: false
|
||||
this._loopState = createInitialRalphTrackerState(); // This sets enabled: false
|
||||
this._todos.clear();
|
||||
this._lineBuffer = '';
|
||||
this._completionPhraseCount.clear();
|
||||
@@ -1389,7 +1395,7 @@ export class InnerLoopTracker extends EventEmitter {
|
||||
* @param loopState - Persisted loop state object
|
||||
* @param todos - Persisted todo items array
|
||||
*/
|
||||
restoreState(loopState: InnerLoopState, todos: InnerTodoItem[]): void {
|
||||
restoreState(loopState: RalphTrackerState, todos: RalphTodoItem[]): void {
|
||||
// Ensure enabled flag exists (backwards compatibility)
|
||||
this._loopState = {
|
||||
...loopState,
|
||||
@@ -588,11 +588,11 @@ export class ScreenManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Update inner loop (Ralph Wiggum) enabled state
|
||||
updateInnerLoopEnabled(sessionId: string, enabled: boolean): void {
|
||||
// Update Ralph enabled state
|
||||
updateRalphEnabled(sessionId: string, enabled: boolean): void {
|
||||
const screen = this.screens.get(sessionId);
|
||||
if (screen) {
|
||||
screen.innerLoopEnabled = enabled;
|
||||
screen.ralphEnabled = enabled;
|
||||
this.saveScreens();
|
||||
}
|
||||
}
|
||||
|
||||
+46
-46
@@ -18,13 +18,13 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
import { SessionState, SessionStatus, SessionConfig, ScreenSession, InnerLoopState, InnerTodoItem } from './types.js';
|
||||
import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem } from './types.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { InnerLoopTracker } from './inner-loop-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
import { ScreenManager } from './screen-manager.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
export type { InnerLoopState, InnerTodoItem } from './types.js';
|
||||
export type { RalphTrackerState, RalphTodoItem } from './types.js';
|
||||
export { withTimeout };
|
||||
|
||||
// ============================================================================
|
||||
@@ -219,12 +219,12 @@ export interface SessionEvents {
|
||||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||||
/** Auto-compact triggered due to token threshold */
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||||
/** Inner loop (Ralph Wiggum) state changed */
|
||||
innerLoopUpdate: (state: InnerLoopState) => void;
|
||||
/** Inner loop todo list updated */
|
||||
innerTodoUpdate: (todos: InnerTodoItem[]) => void;
|
||||
/** Inner loop completion phrase detected */
|
||||
innerCompletionDetected: (phrase: string) => void;
|
||||
/** Ralph loop state changed */
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
/** Ralph todo list updated */
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
/** Ralph completion phrase detected */
|
||||
ralphCompletionDetected: (phrase: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,8 +320,8 @@ export class Session extends EventEmitter {
|
||||
private _screenSession: ScreenSession | null = null;
|
||||
private _useScreen: boolean = false;
|
||||
|
||||
// Inner loop tracking (Ralph Wiggum loops and todo lists inside Claude Code)
|
||||
private _innerLoopTracker: InnerLoopTracker;
|
||||
// Ralph tracking (Ralph Wiggum loops and todo lists inside Claude Code)
|
||||
private _ralphTracker: RalphTracker;
|
||||
|
||||
// Store handler references for cleanup (prevents memory leaks)
|
||||
private _taskTrackerHandlers: {
|
||||
@@ -331,9 +331,9 @@ export class Session extends EventEmitter {
|
||||
taskFailed: (task: BackgroundTask, error: string) => void;
|
||||
} | null = null;
|
||||
|
||||
private _innerLoopHandlers: {
|
||||
loopUpdate: (state: InnerLoopState) => void;
|
||||
todoUpdate: (todos: InnerTodoItem[]) => void;
|
||||
private _ralphHandlers: {
|
||||
loopUpdate: (state: RalphTrackerState) => void;
|
||||
todoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
completionDetected: (phrase: string) => void;
|
||||
} | null = null;
|
||||
|
||||
@@ -369,16 +369,16 @@ export class Session extends EventEmitter {
|
||||
this._taskTracker.on('taskCompleted', this._taskTrackerHandlers.taskCompleted);
|
||||
this._taskTracker.on('taskFailed', this._taskTrackerHandlers.taskFailed);
|
||||
|
||||
// Initialize inner loop tracker and forward events (store handlers for cleanup)
|
||||
this._innerLoopTracker = new InnerLoopTracker();
|
||||
this._innerLoopHandlers = {
|
||||
loopUpdate: (state) => this.emit('innerLoopUpdate', state),
|
||||
todoUpdate: (todos) => this.emit('innerTodoUpdate', todos),
|
||||
completionDetected: (phrase) => this.emit('innerCompletionDetected', phrase),
|
||||
// Initialize Ralph tracker and forward events (store handlers for cleanup)
|
||||
this._ralphTracker = new RalphTracker();
|
||||
this._ralphHandlers = {
|
||||
loopUpdate: (state) => this.emit('ralphLoopUpdate', state),
|
||||
todoUpdate: (todos) => this.emit('ralphTodoUpdate', todos),
|
||||
completionDetected: (phrase) => this.emit('ralphCompletionDetected', phrase),
|
||||
};
|
||||
this._innerLoopTracker.on('loopUpdate', this._innerLoopHandlers.loopUpdate);
|
||||
this._innerLoopTracker.on('todoUpdate', this._innerLoopHandlers.todoUpdate);
|
||||
this._innerLoopTracker.on('completionDetected', this._innerLoopHandlers.completionDetected);
|
||||
this._ralphTracker.on('loopUpdate', this._ralphHandlers.loopUpdate);
|
||||
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
|
||||
this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected);
|
||||
}
|
||||
|
||||
get status(): SessionStatus {
|
||||
@@ -449,21 +449,21 @@ export class Session extends EventEmitter {
|
||||
return this._taskTracker.getStats();
|
||||
}
|
||||
|
||||
// Inner loop tracking getters
|
||||
get innerLoopTracker(): InnerLoopTracker {
|
||||
return this._innerLoopTracker;
|
||||
// Ralph tracking getters
|
||||
get ralphTracker(): RalphTracker {
|
||||
return this._ralphTracker;
|
||||
}
|
||||
|
||||
get innerLoopState(): InnerLoopState {
|
||||
return this._innerLoopTracker.loopState;
|
||||
get ralphLoopState(): RalphTrackerState {
|
||||
return this._ralphTracker.loopState;
|
||||
}
|
||||
|
||||
get innerTodos(): InnerTodoItem[] {
|
||||
return this._innerLoopTracker.todos;
|
||||
get ralphTodos(): RalphTodoItem[] {
|
||||
return this._ralphTracker.todos;
|
||||
}
|
||||
|
||||
get innerTodoStats(): { total: number; pending: number; inProgress: number; completed: number } {
|
||||
return this._innerLoopTracker.getTodoStats();
|
||||
get ralphTodoStats(): { total: number; pending: number; inProgress: number; completed: number } {
|
||||
return this._ralphTracker.getTodoStats();
|
||||
}
|
||||
|
||||
// Token tracking getters and setters
|
||||
@@ -582,10 +582,10 @@ export class Session extends EventEmitter {
|
||||
enabled: this._autoClearEnabled,
|
||||
threshold: this._autoClearThreshold,
|
||||
},
|
||||
// Inner loop tracking state
|
||||
innerLoop: this._innerLoopTracker.loopState,
|
||||
innerTodos: this._innerLoopTracker.todos,
|
||||
innerTodoStats: this._innerLoopTracker.getTodoStats(),
|
||||
// Ralph tracking state
|
||||
ralphLoop: this._ralphTracker.loopState,
|
||||
ralphTodos: this._ralphTracker.todos,
|
||||
ralphTodoStats: this._ralphTracker.getTodoStats(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -726,8 +726,8 @@ export class Session extends EventEmitter {
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
|
||||
// Forward to inner loop tracker to detect Ralph loops and todos
|
||||
this._innerLoopTracker.processTerminalData(data);
|
||||
// Forward to Ralph tracker to detect Ralph loops and todos
|
||||
this._ralphTracker.processTerminalData(data);
|
||||
|
||||
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
||||
this.parseTokensFromStatusLine(data);
|
||||
@@ -1344,7 +1344,7 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listeners from TaskTracker and InnerLoopTracker.
|
||||
* Remove event listeners from TaskTracker and RalphTracker.
|
||||
* Prevents memory leaks by ensuring handlers don't persist after session stop.
|
||||
*/
|
||||
private cleanupTrackerListeners(): void {
|
||||
@@ -1357,12 +1357,12 @@ export class Session extends EventEmitter {
|
||||
this._taskTrackerHandlers = null;
|
||||
}
|
||||
|
||||
// Remove InnerLoopTracker handlers
|
||||
if (this._innerLoopHandlers) {
|
||||
this._innerLoopTracker.off('loopUpdate', this._innerLoopHandlers.loopUpdate);
|
||||
this._innerLoopTracker.off('todoUpdate', this._innerLoopHandlers.todoUpdate);
|
||||
this._innerLoopTracker.off('completionDetected', this._innerLoopHandlers.completionDetected);
|
||||
this._innerLoopHandlers = null;
|
||||
// Remove RalphTracker handlers
|
||||
if (this._ralphHandlers) {
|
||||
this._ralphTracker.off('loopUpdate', this._ralphHandlers.loopUpdate);
|
||||
this._ralphTracker.off('todoUpdate', this._ralphHandlers.todoUpdate);
|
||||
this._ralphTracker.off('completionDetected', this._ralphHandlers.completionDetected);
|
||||
this._ralphHandlers = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1527,6 +1527,6 @@ export class Session extends EventEmitter {
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._taskTracker.clear();
|
||||
this._innerLoopTracker.clear();
|
||||
this._ralphTracker.clear();
|
||||
}
|
||||
}
|
||||
|
||||
+46
-46
@@ -8,7 +8,7 @@
|
||||
* - `state.json`: Main app state (sessions, tasks, config)
|
||||
* - `state-inner.json`: Inner loop state (todos, Ralph loop state per session)
|
||||
*
|
||||
* The separation reduces write frequency since inner loop state changes rapidly
|
||||
* The separation reduces write frequency since Ralph state changes rapidly
|
||||
* during Ralph Wiggum loops.
|
||||
*
|
||||
* @module state-store
|
||||
@@ -17,7 +17,7 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { AppState, createInitialState, InnerSessionState, createInitialInnerSessionState } from './types.js';
|
||||
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState } from './types.js';
|
||||
|
||||
/** Debounce delay for batching state writes (ms) */
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
@@ -50,17 +50,17 @@ export class StateStore {
|
||||
private dirty: boolean = false;
|
||||
|
||||
// Inner state storage (separate from main state to reduce write frequency)
|
||||
private innerStates: Map<string, InnerSessionState> = new Map();
|
||||
private innerStatePath: string;
|
||||
private innerStateSaveTimeout: NodeJS.Timeout | null = null;
|
||||
private innerStateDirty: boolean = false;
|
||||
private ralphStates: Map<string, RalphSessionState> = new Map();
|
||||
private ralphStatePath: string;
|
||||
private ralphStateSaveTimeout: NodeJS.Timeout | null = null;
|
||||
private ralphStateDirty: boolean = false;
|
||||
|
||||
constructor(filePath?: string) {
|
||||
this.filePath = filePath || join(homedir(), '.claudeman', 'state.json');
|
||||
this.innerStatePath = this.filePath.replace('.json', '-inner.json');
|
||||
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
|
||||
this.state = this.load();
|
||||
this.state.config.stateFilePath = this.filePath;
|
||||
this.loadInnerStates();
|
||||
this.loadRalphStates();
|
||||
}
|
||||
|
||||
private ensureDir(): void {
|
||||
@@ -203,20 +203,20 @@ export class StateStore {
|
||||
reset(): void {
|
||||
this.state = createInitialState();
|
||||
this.state.config.stateFilePath = this.filePath;
|
||||
this.innerStates.clear();
|
||||
this.ralphStates.clear();
|
||||
this.saveNow(); // Immediate save for reset operations
|
||||
this.saveInnerStatesNow();
|
||||
this.saveRalphStatesNow();
|
||||
}
|
||||
|
||||
// ========== Inner State Methods (Ralph Loop tracking) ==========
|
||||
|
||||
private loadInnerStates(): void {
|
||||
private loadRalphStates(): void {
|
||||
try {
|
||||
if (existsSync(this.innerStatePath)) {
|
||||
const data = readFileSync(this.innerStatePath, 'utf-8');
|
||||
const parsed = JSON.parse(data) as Record<string, InnerSessionState>;
|
||||
if (existsSync(this.ralphStatePath)) {
|
||||
const data = readFileSync(this.ralphStatePath, 'utf-8');
|
||||
const parsed = JSON.parse(data) as Record<string, RalphSessionState>;
|
||||
for (const [sessionId, state] of Object.entries(parsed)) {
|
||||
this.innerStates.set(sessionId, state);
|
||||
this.ralphStates.set(sessionId, state);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -225,43 +225,43 @@ export class StateStore {
|
||||
}
|
||||
|
||||
// Debounced save for inner states
|
||||
private saveInnerStates(): void {
|
||||
this.innerStateDirty = true;
|
||||
if (this.innerStateSaveTimeout) {
|
||||
private saveRalphStates(): void {
|
||||
this.ralphStateDirty = true;
|
||||
if (this.ralphStateSaveTimeout) {
|
||||
return; // Already scheduled
|
||||
}
|
||||
this.innerStateSaveTimeout = setTimeout(() => {
|
||||
this.saveInnerStatesNow();
|
||||
this.ralphStateSaveTimeout = setTimeout(() => {
|
||||
this.saveRalphStatesNow();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// Immediate save for inner states
|
||||
private saveInnerStatesNow(): void {
|
||||
if (this.innerStateSaveTimeout) {
|
||||
clearTimeout(this.innerStateSaveTimeout);
|
||||
this.innerStateSaveTimeout = null;
|
||||
private saveRalphStatesNow(): void {
|
||||
if (this.ralphStateSaveTimeout) {
|
||||
clearTimeout(this.ralphStateSaveTimeout);
|
||||
this.ralphStateSaveTimeout = null;
|
||||
}
|
||||
if (!this.innerStateDirty) {
|
||||
if (!this.ralphStateDirty) {
|
||||
return;
|
||||
}
|
||||
this.innerStateDirty = false;
|
||||
this.ralphStateDirty = false;
|
||||
this.ensureDir();
|
||||
const data: Record<string, InnerSessionState> = {};
|
||||
for (const [sessionId, state] of this.innerStates) {
|
||||
const data: Record<string, RalphSessionState> = {};
|
||||
for (const [sessionId, state] of this.ralphStates) {
|
||||
data[sessionId] = state;
|
||||
}
|
||||
writeFileSync(this.innerStatePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
writeFileSync(this.ralphStatePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/** Returns inner state for a session, or null if not found. */
|
||||
getInnerState(sessionId: string): InnerSessionState | null {
|
||||
return this.innerStates.get(sessionId) || null;
|
||||
getRalphState(sessionId: string): RalphSessionState | null {
|
||||
return this.ralphStates.get(sessionId) || null;
|
||||
}
|
||||
|
||||
/** Sets inner state for a session and triggers a debounced save. */
|
||||
setInnerState(sessionId: string, state: InnerSessionState): void {
|
||||
this.innerStates.set(sessionId, state);
|
||||
this.saveInnerStates();
|
||||
setRalphState(sessionId: string, state: RalphSessionState): void {
|
||||
this.ralphStates.set(sessionId, state);
|
||||
this.saveRalphStates();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,34 +269,34 @@ export class StateStore {
|
||||
* Creates initial state if none exists.
|
||||
* @returns The updated inner state.
|
||||
*/
|
||||
updateInnerState(sessionId: string, updates: Partial<InnerSessionState>): InnerSessionState {
|
||||
let state = this.innerStates.get(sessionId);
|
||||
updateRalphState(sessionId: string, updates: Partial<RalphSessionState>): RalphSessionState {
|
||||
let state = this.ralphStates.get(sessionId);
|
||||
if (!state) {
|
||||
state = createInitialInnerSessionState(sessionId);
|
||||
state = createInitialRalphSessionState(sessionId);
|
||||
}
|
||||
state = { ...state, ...updates, lastUpdated: Date.now() };
|
||||
this.innerStates.set(sessionId, state);
|
||||
this.saveInnerStates();
|
||||
this.ralphStates.set(sessionId, state);
|
||||
this.saveRalphStates();
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Removes inner state for a session and triggers a debounced save. */
|
||||
removeInnerState(sessionId: string): void {
|
||||
if (this.innerStates.has(sessionId)) {
|
||||
this.innerStates.delete(sessionId);
|
||||
this.saveInnerStates();
|
||||
removeRalphState(sessionId: string): void {
|
||||
if (this.ralphStates.has(sessionId)) {
|
||||
this.ralphStates.delete(sessionId);
|
||||
this.saveRalphStates();
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a copy of all inner states as a Map. */
|
||||
getAllInnerStates(): Map<string, InnerSessionState> {
|
||||
return new Map(this.innerStates);
|
||||
getAllRalphStates(): Map<string, RalphSessionState> {
|
||||
return new Map(this.ralphStates);
|
||||
}
|
||||
|
||||
/** Flushes all pending saves (main and inner state). Call before shutdown. */
|
||||
flushAll(): void {
|
||||
this.saveNow();
|
||||
this.saveInnerStatesNow();
|
||||
this.saveRalphStatesNow();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -9,7 +9,7 @@
|
||||
* - **Main view**: Active session management with:
|
||||
* - TabBar: Session tabs with switching
|
||||
* - TerminalView: Live terminal output display
|
||||
* - RalphPanel: Inner loop tracking (conditional)
|
||||
* - RalphPanel: Ralph loop tracking (conditional)
|
||||
* - StatusBar: Session info and keyboard hints
|
||||
*
|
||||
* Keyboard shortcuts are handled globally via Ink's useInput hook.
|
||||
@@ -116,8 +116,8 @@ export function App(): React.ReactElement {
|
||||
prevSession,
|
||||
sendInput,
|
||||
terminalOutput,
|
||||
innerLoopState,
|
||||
innerTodos,
|
||||
ralphLoopState,
|
||||
ralphTodos,
|
||||
respawnStatus,
|
||||
cases,
|
||||
lastUsedCase,
|
||||
@@ -427,7 +427,7 @@ export function App(): React.ReactElement {
|
||||
}
|
||||
|
||||
// Check if Ralph panel should be visible (enabled and has data)
|
||||
const showRalphPanel = innerLoopState?.enabled && (innerLoopState.active || innerTodos.length > 0);
|
||||
const showRalphPanel = ralphLoopState?.enabled && (ralphLoopState.active || ralphTodos.length > 0);
|
||||
|
||||
// Adjust terminal height if Ralph panel is shown
|
||||
const adjustedTerminalHeight = showRalphPanel ? terminalHeight - 6 : terminalHeight;
|
||||
@@ -449,8 +449,8 @@ export function App(): React.ReactElement {
|
||||
|
||||
{showRalphPanel && (
|
||||
<RalphPanel
|
||||
loopState={innerLoopState}
|
||||
todos={innerTodos}
|
||||
loopState={ralphLoopState}
|
||||
todos={ralphTodos}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
* - Progress through todos with visual indicators
|
||||
* - Cycle count and elapsed time tracking
|
||||
*
|
||||
* @see {@link file://./../../inner-loop-tracker.ts} for detection logic
|
||||
* @see {@link file://./../../ralph-tracker.ts} for detection logic
|
||||
* @see {@link file://./../../../docs/ralph-wiggum-guide.md} for full documentation
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { InnerLoopState, InnerTodoItem } from '../../types.js';
|
||||
import type { RalphTrackerState, RalphTodoItem } from '../../types.js';
|
||||
|
||||
interface RalphPanelProps {
|
||||
loopState: InnerLoopState | null;
|
||||
todos: InnerTodoItem[];
|
||||
loopState: RalphTrackerState | null;
|
||||
todos: RalphTodoItem[];
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ function formatElapsed(hours: number | null): string {
|
||||
* Only renders when loop is enabled and has relevant data to show.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @param props.loopState - Current loop state from InnerLoopTracker
|
||||
* @param props.loopState - Current loop state from RalphTracker
|
||||
* @param props.todos - Array of todo items being tracked
|
||||
* @param props.visible - Whether the panel should be visible (default: true)
|
||||
* @returns The panel element or null if hidden/disabled
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* This hook provides a complete interface for managing Claude sessions:
|
||||
* - Session discovery from ~/.claudeman/screens.json
|
||||
* - Terminal output polling via GNU screen hardcopy
|
||||
* - Inner loop state tracking for Ralph Wiggum loops
|
||||
* - Ralph state tracking for Ralph Wiggum loops
|
||||
* - Respawn status monitoring via the Claudeman API
|
||||
*
|
||||
* @example
|
||||
@@ -29,12 +29,12 @@ import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { execSync, exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { ScreenSession, InnerLoopState, InnerTodoItem, InnerSessionState } from '../../types.js';
|
||||
import type { ScreenSession, RalphTrackerState, RalphTodoItem, RalphSessionState } from '../../types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
||||
const INNER_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
|
||||
const RALPH_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
|
||||
const SETTINGS_FILE = join(homedir(), '.claudeman', 'settings.json');
|
||||
const OUTPUT_POLL_INTERVAL = 300; // Poll terminal output every 300ms (faster refresh)
|
||||
|
||||
@@ -149,8 +149,8 @@ interface SessionManagerState {
|
||||
activeSessionId: string | null;
|
||||
activeSession: ScreenSession | null;
|
||||
terminalOutput: string;
|
||||
innerLoopState: InnerLoopState | null;
|
||||
innerTodos: InnerTodoItem[];
|
||||
ralphLoopState: RalphTrackerState | null;
|
||||
ralphTodos: RalphTodoItem[];
|
||||
respawnStatus: RespawnStatus | null;
|
||||
cases: CaseInfo[];
|
||||
lastUsedCase: string | null;
|
||||
@@ -237,18 +237,18 @@ function loadSessions(): ScreenSession[] {
|
||||
*
|
||||
* @description
|
||||
* Reads ~/.claudeman/state-inner.json which contains per-session
|
||||
* tracking of inner loops, todos, and completion phrases.
|
||||
* tracking of Ralph loops, todos, and completion phrases.
|
||||
*
|
||||
* @param sessionId - The UUID of the session to load state for
|
||||
* @returns The inner session state or null if not found
|
||||
* @returns The Ralph session state or null if not found
|
||||
*/
|
||||
function loadInnerState(sessionId: string): InnerSessionState | null {
|
||||
function loadRalphState(sessionId: string): RalphSessionState | null {
|
||||
try {
|
||||
if (!existsSync(INNER_STATE_FILE)) {
|
||||
if (!existsSync(RALPH_STATE_FILE)) {
|
||||
return null;
|
||||
}
|
||||
const data = readFileSync(INNER_STATE_FILE, 'utf-8');
|
||||
const allStates = JSON.parse(data) as Record<string, InnerSessionState>;
|
||||
const data = readFileSync(RALPH_STATE_FILE, 'utf-8');
|
||||
const allStates = JSON.parse(data) as Record<string, RalphSessionState>;
|
||||
return allStates[sessionId] || null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -284,7 +284,7 @@ function loadLastUsedCase(): string | null {
|
||||
* Provides complete session lifecycle management:
|
||||
* - Automatic session discovery and status monitoring
|
||||
* - Terminal output polling (500ms interval)
|
||||
* - Inner loop state tracking (500ms interval)
|
||||
* - Ralph state tracking (500ms interval)
|
||||
* - Respawn status polling via API (2000ms interval)
|
||||
* - Session CRUD operations via Claudeman API
|
||||
*
|
||||
@@ -311,8 +311,8 @@ export function useSessionManager(): SessionManagerState {
|
||||
const [sessions, setSessions] = useState<ScreenSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [terminalOutput, setTerminalOutput] = useState<string>('');
|
||||
const [innerLoopState, setInnerLoopState] = useState<InnerLoopState | null>(null);
|
||||
const [innerTodos, setInnerTodos] = useState<InnerTodoItem[]>([]);
|
||||
const [ralphLoopState, setRalphTrackerState] = useState<RalphTrackerState | null>(null);
|
||||
const [ralphTodos, setInnerTodos] = useState<RalphTodoItem[]>([]);
|
||||
const [respawnStatus, setRespawnStatus] = useState<RespawnStatus | null>(null);
|
||||
const [cases, setCases] = useState<CaseInfo[]>([]);
|
||||
const [lastUsedCase, setLastUsedCase] = useState<string | null>(loadLastUsedCase);
|
||||
@@ -346,30 +346,30 @@ export function useSessionManager(): SessionManagerState {
|
||||
// Get active session
|
||||
const activeSession = sessions.find((s) => s.sessionId === activeSessionId) || null;
|
||||
|
||||
// Poll inner state for active session
|
||||
// Poll Ralph state for active session
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) {
|
||||
setInnerLoopState(null);
|
||||
setRalphTrackerState(null);
|
||||
setInnerTodos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const pollInnerState = () => {
|
||||
const state = loadInnerState(activeSessionId);
|
||||
const pollRalphState = () => {
|
||||
const state = loadRalphState(activeSessionId);
|
||||
if (state) {
|
||||
setInnerLoopState(state.loop);
|
||||
setRalphTrackerState(state.loop);
|
||||
setInnerTodos(state.todos);
|
||||
} else {
|
||||
setInnerLoopState(null);
|
||||
setRalphTrackerState(null);
|
||||
setInnerTodos([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial poll
|
||||
pollInnerState();
|
||||
pollRalphState();
|
||||
|
||||
// Set up polling interval (same as output polling)
|
||||
const intervalId = setInterval(pollInnerState, OUTPUT_POLL_INTERVAL);
|
||||
const intervalId = setInterval(pollRalphState, OUTPUT_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
@@ -809,8 +809,8 @@ export function useSessionManager(): SessionManagerState {
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
terminalOutput,
|
||||
innerLoopState,
|
||||
innerTodos,
|
||||
ralphLoopState,
|
||||
ralphTodos,
|
||||
respawnStatus,
|
||||
cases,
|
||||
lastUsedCase,
|
||||
|
||||
+20
-20
@@ -475,8 +475,8 @@ export interface ScreenSession {
|
||||
name?: string;
|
||||
/** Persisted respawn controller configuration (restored on server restart) */
|
||||
respawnConfig?: PersistedRespawnConfig;
|
||||
/** Whether inner loop (Ralph Wiggum) tracking is enabled */
|
||||
innerLoopEnabled?: boolean;
|
||||
/** Whether Ralph / Todo tracking is enabled */
|
||||
ralphEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -533,12 +533,12 @@ export const DEFAULT_CONFIG: AppConfig = {
|
||||
*/
|
||||
|
||||
/** Status of a detected todo item */
|
||||
export type InnerTodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
export type RalphTodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
/**
|
||||
* State of an inner loop (Ralph Wiggum loop inside Claude Code)
|
||||
* State of per-session Ralph / Todo tracking (detected from Claude output)
|
||||
*/
|
||||
export interface InnerLoopState {
|
||||
export interface RalphTrackerState {
|
||||
/** Whether the tracker is actively monitoring (disabled by default) */
|
||||
enabled: boolean;
|
||||
/** Whether a loop is currently active */
|
||||
@@ -560,27 +560,27 @@ export interface InnerLoopState {
|
||||
/**
|
||||
* A detected todo item from Claude Code output
|
||||
*/
|
||||
export interface InnerTodoItem {
|
||||
export interface RalphTodoItem {
|
||||
/** Unique identifier based on content hash */
|
||||
id: string;
|
||||
/** Todo item text content */
|
||||
content: string;
|
||||
/** Current status */
|
||||
status: InnerTodoStatus;
|
||||
status: RalphTodoStatus;
|
||||
/** Timestamp when detected */
|
||||
detectedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete inner state for a session
|
||||
* Complete Ralph/todo state for a session
|
||||
*/
|
||||
export interface InnerSessionState {
|
||||
export interface RalphSessionState {
|
||||
/** Session this state belongs to */
|
||||
sessionId: string;
|
||||
/** Loop tracking state */
|
||||
loop: InnerLoopState;
|
||||
loop: RalphTrackerState;
|
||||
/** Detected todo items */
|
||||
todos: InnerTodoItem[];
|
||||
todos: RalphTodoItem[];
|
||||
/** Timestamp of last update */
|
||||
lastUpdated: number;
|
||||
}
|
||||
@@ -588,15 +588,15 @@ export interface InnerSessionState {
|
||||
/**
|
||||
* Map of session ID to inner state
|
||||
*/
|
||||
export interface InnerStateRecord {
|
||||
[sessionId: string]: InnerSessionState;
|
||||
export interface RalphStateRecord {
|
||||
[sessionId: string]: RalphSessionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates initial inner loop state
|
||||
* @returns Fresh inner loop state with defaults
|
||||
* Creates initial Ralph tracker state
|
||||
* @returns Fresh Ralph tracker state with defaults
|
||||
*/
|
||||
export function createInitialInnerLoopState(): InnerLoopState {
|
||||
export function createInitialRalphTrackerState(): RalphTrackerState {
|
||||
return {
|
||||
enabled: false, // Disabled by default, auto-enables when Ralph patterns detected
|
||||
active: false,
|
||||
@@ -610,14 +610,14 @@ export function createInitialInnerLoopState(): InnerLoopState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates initial inner session state
|
||||
* Creates initial Ralph session state
|
||||
* @param sessionId Session ID this state belongs to
|
||||
* @returns Fresh inner session state
|
||||
* @returns Fresh Ralph session state
|
||||
*/
|
||||
export function createInitialInnerSessionState(sessionId: string): InnerSessionState {
|
||||
export function createInitialRalphSessionState(sessionId: string): RalphSessionState {
|
||||
return {
|
||||
sessionId,
|
||||
loop: createInitialInnerLoopState(),
|
||||
loop: createInitialRalphTrackerState(),
|
||||
todos: [],
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
+62
-62
@@ -19,9 +19,9 @@ class ClaudemanApp {
|
||||
this.pendingCloseSessionId = null; // Session pending close confirmation
|
||||
this.screenSessions = []; // Screen sessions for process monitor
|
||||
|
||||
// Inner loop/todo state per session
|
||||
this.innerStates = new Map(); // Map<sessionId, { loop, todos }>
|
||||
this.innerStatePanelCollapsed = true; // Default to collapsed
|
||||
// Ralph loop/todo state per session
|
||||
this.ralphStates = new Map(); // Map<sessionId, { loop, todos }>
|
||||
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
||||
|
||||
// Terminal write batching
|
||||
this.pendingWrites = '';
|
||||
@@ -29,7 +29,7 @@ class ClaudemanApp {
|
||||
|
||||
// Render debouncing
|
||||
this.renderSessionTabsTimeout = null;
|
||||
this.renderInnerStatePanelTimeout = null;
|
||||
this.renderRalphStatePanelTimeout = null;
|
||||
this.renderTaskPanelTimeout = null;
|
||||
this.renderScreenSessionsTimeout = null;
|
||||
|
||||
@@ -302,14 +302,14 @@ class ClaudemanApp {
|
||||
const data = JSON.parse(e.data);
|
||||
this.sessions.delete(data.id);
|
||||
this.terminalBuffers.delete(data.id);
|
||||
this.innerStates.delete(data.id); // Clean up inner state for this session
|
||||
this.ralphStates.delete(data.id); // Clean up ralph state for this session
|
||||
if (this.activeSessionId === data.id) {
|
||||
this.activeSessionId = null;
|
||||
this.terminal.clear();
|
||||
this.showWelcome();
|
||||
}
|
||||
this.renderSessionTabs();
|
||||
this.renderInnerStatePanel(); // Update inner panel after session deleted
|
||||
this.renderRalphStatePanel(); // Update ralph panel after session deleted
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('session:terminal', (e) => {
|
||||
@@ -543,18 +543,18 @@ class ClaudemanApp {
|
||||
}
|
||||
});
|
||||
|
||||
// Inner loop/todo events
|
||||
this.eventSource.addEventListener('session:innerLoopUpdate', (e) => {
|
||||
// Ralph loop/todo events
|
||||
this.eventSource.addEventListener('session:ralphLoopUpdate', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.updateInnerState(data.sessionId, { loop: data.state });
|
||||
this.updateRalphState(data.sessionId, { loop: data.state });
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('session:innerTodoUpdate', (e) => {
|
||||
this.eventSource.addEventListener('session:ralphTodoUpdate', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.updateInnerState(data.sessionId, { todos: data.todos });
|
||||
this.updateRalphState(data.sessionId, { todos: data.todos });
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('session:innerCompletionDetected', (e) => {
|
||||
this.eventSource.addEventListener('session:ralphCompletionDetected', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
// Prevent duplicate notifications for the same completion
|
||||
const completionKey = `${data.sessionId}:${data.phrase}`;
|
||||
@@ -568,11 +568,11 @@ class ClaudemanApp {
|
||||
// Clear after 30 seconds to allow re-notification if loop restarts
|
||||
setTimeout(() => this._shownCompletions?.delete(completionKey), 30000);
|
||||
|
||||
// Update inner state to mark loop as inactive
|
||||
const existing = this.innerStates.get(data.sessionId) || {};
|
||||
// Update ralph state to mark loop as inactive
|
||||
const existing = this.ralphStates.get(data.sessionId) || {};
|
||||
if (existing.loop) {
|
||||
existing.loop.active = false;
|
||||
this.updateInnerState(data.sessionId, existing);
|
||||
this.updateRalphState(data.sessionId, existing);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -586,14 +586,14 @@ class ClaudemanApp {
|
||||
|
||||
handleInit(data) {
|
||||
this.sessions.clear();
|
||||
this.innerStates.clear();
|
||||
this.ralphStates.clear();
|
||||
data.sessions.forEach(s => {
|
||||
this.sessions.set(s.id, s);
|
||||
// Load inner state from session data
|
||||
if (s.innerLoop || s.innerTodos) {
|
||||
this.innerStates.set(s.id, {
|
||||
loop: s.innerLoop || null,
|
||||
todos: s.innerTodos || []
|
||||
// Load ralph state from session data
|
||||
if (s.ralphLoop || s.ralphTodos) {
|
||||
this.ralphStates.set(s.id, {
|
||||
loop: s.ralphLoop || null,
|
||||
todos: s.ralphTodos || []
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -807,15 +807,15 @@ class ClaudemanApp {
|
||||
this.renderTaskPanel();
|
||||
}
|
||||
|
||||
// Update inner state panel for this session
|
||||
// Update ralph state panel for this session
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session && (session.innerLoop || session.innerTodos)) {
|
||||
this.updateInnerState(sessionId, {
|
||||
loop: session.innerLoop,
|
||||
todos: session.innerTodos
|
||||
if (session && (session.ralphLoop || session.ralphTodos)) {
|
||||
this.updateRalphState(sessionId, {
|
||||
loop: session.ralphLoop,
|
||||
todos: session.ralphTodos
|
||||
});
|
||||
}
|
||||
this.renderInnerStatePanel();
|
||||
this.renderRalphStatePanel();
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
@@ -828,7 +828,7 @@ class ClaudemanApp {
|
||||
await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' });
|
||||
this.sessions.delete(sessionId);
|
||||
this.terminalBuffers.delete(sessionId);
|
||||
this.innerStates.delete(sessionId);
|
||||
this.ralphStates.delete(sessionId);
|
||||
|
||||
if (this.activeSessionId === sessionId) {
|
||||
this.activeSessionId = null;
|
||||
@@ -839,7 +839,7 @@ class ClaudemanApp {
|
||||
} else {
|
||||
this.terminal.clear();
|
||||
this.showWelcome();
|
||||
this.renderInnerStatePanel(); // Clear inner panel when no sessions
|
||||
this.renderRalphStatePanel(); // Clear ralph panel when no sessions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1088,7 +1088,7 @@ class ClaudemanApp {
|
||||
// Apply global Ralph tracker setting
|
||||
// If enabled: enable the tracker
|
||||
// If disabled: disable auto-enable to prevent pattern-based activation
|
||||
await fetch(`/api/sessions/${createData.session.id}/inner-config`, {
|
||||
await fetch(`/api/sessions/${createData.session.id}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -1518,13 +1518,13 @@ class ClaudemanApp {
|
||||
this.selectDurationPreset('');
|
||||
|
||||
// Populate Ralph Wiggum form with current session values
|
||||
const innerState = this.innerStates.get(sessionId);
|
||||
const ralphState = this.ralphStates.get(sessionId);
|
||||
this.populateRalphForm({
|
||||
enabled: innerState?.loop?.enabled ?? session.innerConfig?.enabled ?? false,
|
||||
completionPhrase: innerState?.loop?.completionPhrase || session.innerLoop?.completionPhrase || '',
|
||||
maxIterations: innerState?.loop?.maxIterations || session.innerLoop?.maxIterations || 0,
|
||||
maxTodos: session.innerConfig?.maxTodos || 50,
|
||||
todoExpirationMinutes: session.innerConfig?.todoExpirationMinutes || 60
|
||||
enabled: ralphState?.loop?.enabled ?? session.ralphConfig?.enabled ?? false,
|
||||
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
|
||||
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
|
||||
maxTodos: session.ralphConfig?.maxTodos || 50,
|
||||
todoExpirationMinutes: session.ralphConfig?.todoExpirationMinutes || 60
|
||||
});
|
||||
|
||||
document.getElementById('sessionOptionsModal').classList.add('active');
|
||||
@@ -1728,7 +1728,7 @@ class ClaudemanApp {
|
||||
const config = this.getRalphConfig();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.editingSessionId}/inner-config`, {
|
||||
const res = await fetch(`/api/sessions/${this.editingSessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
@@ -2177,24 +2177,24 @@ class ClaudemanApp {
|
||||
|
||||
// ========== Enhanced Ralph Wiggum Loop Panel ==========
|
||||
|
||||
updateInnerState(sessionId, updates) {
|
||||
const existing = this.innerStates.get(sessionId) || { loop: null, todos: [] };
|
||||
updateRalphState(sessionId, updates) {
|
||||
const existing = this.ralphStates.get(sessionId) || { loop: null, todos: [] };
|
||||
const updated = { ...existing, ...updates };
|
||||
this.innerStates.set(sessionId, updated);
|
||||
this.ralphStates.set(sessionId, updated);
|
||||
|
||||
// Re-render if this is the active session
|
||||
if (sessionId === this.activeSessionId) {
|
||||
this.renderInnerStatePanel();
|
||||
this.renderRalphStatePanel();
|
||||
}
|
||||
}
|
||||
|
||||
toggleInnerStatePanel() {
|
||||
toggleRalphStatePanel() {
|
||||
// Preserve xterm scroll position to prevent jump when panel height changes
|
||||
const xtermViewport = this.terminal?.element?.querySelector('.xterm-viewport');
|
||||
const scrollTop = xtermViewport?.scrollTop;
|
||||
|
||||
this.innerStatePanelCollapsed = !this.innerStatePanelCollapsed;
|
||||
this.renderInnerStatePanel();
|
||||
this.ralphStatePanelCollapsed = !this.ralphStatePanelCollapsed;
|
||||
this.renderRalphStatePanel();
|
||||
|
||||
// Restore scroll position and refit terminal after layout change
|
||||
requestAnimationFrame(() => {
|
||||
@@ -2213,19 +2213,19 @@ class ClaudemanApp {
|
||||
if (!this.activeSessionId) return;
|
||||
|
||||
// Disable tracker via API
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/inner-config`, {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: false })
|
||||
});
|
||||
|
||||
// Clear local state and hide panel
|
||||
this.innerStates.delete(this.activeSessionId);
|
||||
this.renderInnerStatePanel();
|
||||
this.ralphStates.delete(this.activeSessionId);
|
||||
this.renderRalphStatePanel();
|
||||
}
|
||||
|
||||
toggleRalphDetach() {
|
||||
const panel = this.$('innerStatePanel');
|
||||
const panel = this.$('ralphStatePanel');
|
||||
const detachBtn = this.$('ralphDetachBtn');
|
||||
|
||||
if (!panel) return;
|
||||
@@ -2245,7 +2245,7 @@ class ClaudemanApp {
|
||||
// Detach as floating window
|
||||
panel.classList.add('detached');
|
||||
// Expand when detaching for better visibility
|
||||
this.innerStatePanelCollapsed = false;
|
||||
this.ralphStatePanelCollapsed = false;
|
||||
panel.classList.remove('collapsed');
|
||||
if (detachBtn) {
|
||||
detachBtn.innerHTML = '⊞'; // Attach icon (squared plus - dock back)
|
||||
@@ -2254,11 +2254,11 @@ class ClaudemanApp {
|
||||
// Setup drag functionality
|
||||
this.setupRalphDrag();
|
||||
}
|
||||
this.renderInnerStatePanel();
|
||||
this.renderRalphStatePanel();
|
||||
}
|
||||
|
||||
setupRalphDrag() {
|
||||
const panel = this.$('innerStatePanel');
|
||||
const panel = this.$('ralphStatePanel');
|
||||
const header = this.$('ralphSummary');
|
||||
|
||||
if (!panel || !header) return;
|
||||
@@ -2313,23 +2313,23 @@ class ClaudemanApp {
|
||||
header.addEventListener('mousedown', onMouseDown);
|
||||
}
|
||||
|
||||
renderInnerStatePanel() {
|
||||
renderRalphStatePanel() {
|
||||
// Debounce renders at 50ms to prevent excessive DOM updates
|
||||
if (this.renderInnerStatePanelTimeout) {
|
||||
clearTimeout(this.renderInnerStatePanelTimeout);
|
||||
if (this.renderRalphStatePanelTimeout) {
|
||||
clearTimeout(this.renderRalphStatePanelTimeout);
|
||||
}
|
||||
this.renderInnerStatePanelTimeout = setTimeout(() => {
|
||||
this._renderInnerStatePanelImmediate();
|
||||
this.renderRalphStatePanelTimeout = setTimeout(() => {
|
||||
this._renderRalphStatePanelImmediate();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
_renderInnerStatePanelImmediate() {
|
||||
const panel = this.$('innerStatePanel');
|
||||
_renderRalphStatePanelImmediate() {
|
||||
const panel = this.$('ralphStatePanel');
|
||||
const toggle = this.$('ralphToggle');
|
||||
|
||||
if (!panel) return;
|
||||
|
||||
const state = this.innerStates.get(this.activeSessionId);
|
||||
const state = this.ralphStates.get(this.activeSessionId);
|
||||
|
||||
// Check if there's anything to show
|
||||
// Only show panel if tracker is enabled OR there's active state to display
|
||||
@@ -2360,7 +2360,7 @@ class ClaudemanApp {
|
||||
this.updateRalphStats(state?.loop, completed, total);
|
||||
|
||||
// Handle collapsed/expanded state
|
||||
if (this.innerStatePanelCollapsed) {
|
||||
if (this.ralphStatePanelCollapsed) {
|
||||
panel.classList.add('collapsed');
|
||||
if (toggle) toggle.innerHTML = '▼'; // Down arrow when collapsed (click to expand)
|
||||
} else {
|
||||
@@ -2510,8 +2510,8 @@ class ClaudemanApp {
|
||||
if (!grid) return;
|
||||
|
||||
if (todos.length === 0) {
|
||||
if (grid.children.length !== 1 || !grid.querySelector('.inner-state-empty')) {
|
||||
grid.innerHTML = '<div class="inner-state-empty">No tasks detected</div>';
|
||||
if (grid.children.length !== 1 || !grid.querySelector('.ralph-state-empty')) {
|
||||
grid.innerHTML = '<div class="ralph-state-empty">No tasks detected</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,11 +72,11 @@
|
||||
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">■</button>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Ralph Wiggum Loop Panel -->
|
||||
<div class="ralph-panel collapsed" id="innerStatePanel" style="display: none;">
|
||||
<!-- Ralph / Todo Tracker Panel -->
|
||||
<div class="ralph-panel collapsed" id="ralphStatePanel" style="display: none;">
|
||||
<!-- Collapsed Summary Bar -->
|
||||
<div class="ralph-summary" id="ralphSummary">
|
||||
<div class="ralph-summary-content" onclick="app.toggleInnerStatePanel()">
|
||||
<div class="ralph-summary-content" onclick="app.toggleRalphStatePanel()">
|
||||
<div class="ralph-ring-mini" id="ralphRingMini">
|
||||
<svg viewBox="0 0 36 36" class="ralph-ring-svg">
|
||||
<defs>
|
||||
@@ -91,7 +91,7 @@
|
||||
<span class="ralph-ring-text" id="ralphRingMiniText">0%</span>
|
||||
</div>
|
||||
<div class="ralph-summary-info">
|
||||
<span class="ralph-title">Ralph Wiggum Tracker</span>
|
||||
<span class="ralph-title">Ralph / Todo Tracker</span>
|
||||
<span class="ralph-status-badge" id="ralphStatusBadge">
|
||||
<span class="ralph-status-dot"></span>
|
||||
<span class="ralph-status-text">Idle</span>
|
||||
@@ -268,7 +268,7 @@
|
||||
<!-- Modal Tabs -->
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="respawn" onclick="app.switchOptionsTab('respawn')">Respawn</button>
|
||||
<button class="modal-tab-btn" data-tab="ralph" onclick="app.switchOptionsTab('ralph')">Ralph Wiggum</button>
|
||||
<button class="modal-tab-btn" data-tab="ralph" onclick="app.switchOptionsTab('ralph')">Ralph / Todo Tracker</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Respawn Tab Content -->
|
||||
@@ -374,7 +374,7 @@
|
||||
<input type="checkbox" id="modalRalphEnabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Enable Ralph Wiggum tracker for this session (auto-detects loops and todos)</span>
|
||||
<span class="form-hint">Enable Ralph / Todo tracker for this session (auto-detects loops and todos)</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
@@ -505,7 +505,7 @@
|
||||
<span class="form-hint">Comma-separated list of tools to allow</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Enable Ralph Tracker</label>
|
||||
<label>Enable Ralph / Todo Tracker</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsRalphEnabled">
|
||||
<span class="slider"></span>
|
||||
|
||||
@@ -962,8 +962,8 @@ body {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Empty state (legacy support) */
|
||||
.inner-state-empty {
|
||||
/* Empty state */
|
||||
.ralph-state-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 0.5rem;
|
||||
|
||||
+147
-123
@@ -17,11 +17,12 @@ import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, ClaudeMessage, type BackgroundTask, type InnerLoopState, type InnerTodoItem } from '../session.js';
|
||||
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem } from '../session.js';
|
||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||||
import { ScreenManager } from '../screen-manager.js';
|
||||
import { getStore } from '../state-store.js';
|
||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
getErrorMessage,
|
||||
@@ -73,59 +74,53 @@ const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
|
||||
// Maximum concurrent sessions to prevent resource exhaustion
|
||||
const MAX_CONCURRENT_SESSIONS = 50;
|
||||
|
||||
// Pattern to extract completion phrase from CLAUDE.md
|
||||
// Matches <promise>PHRASE</promise> with optional whitespace
|
||||
const CLAUDE_MD_PROMISE_PATTERN = /<promise>\s*([A-Z0-9_]+)\s*<\/promise>/gi;
|
||||
|
||||
/**
|
||||
* Extract completion phrase from CLAUDE.md content.
|
||||
* Looks for <promise>PHRASE</promise> pattern.
|
||||
* Auto-configure Ralph tracker for a session.
|
||||
*
|
||||
* Handles multiple variations:
|
||||
* - Raw text: <promise>PHRASE</promise>
|
||||
* - In backticks: `<promise>PHRASE</promise>`
|
||||
* - With whitespace: <promise> PHRASE </promise>
|
||||
* - Multiple occurrences: returns the first one
|
||||
* Priority order:
|
||||
* 1. .claude/ralph-loop.local.md (official Ralph Wiggum plugin state)
|
||||
* 2. CLAUDE.md <promise> tags (fallback)
|
||||
*
|
||||
* @param claudeMdPath - Path to CLAUDE.md file
|
||||
* @returns The completion phrase (uppercase), or null if not found
|
||||
* The ralph-loop.local.md file has priority because it contains
|
||||
* the exact configuration from an active Ralph loop session.
|
||||
*/
|
||||
function extractCompletionPhrase(claudeMdPath: string): string | null {
|
||||
try {
|
||||
if (!existsSync(claudeMdPath)) return null;
|
||||
const content = readFileSync(claudeMdPath, 'utf-8');
|
||||
function autoConfigureRalph(session: Session, workingDir: string, broadcast: (event: string, data: unknown) => void): void {
|
||||
// First, try to read the official Ralph Wiggum plugin state file
|
||||
const ralphConfig = parseRalphLoopConfig(workingDir);
|
||||
|
||||
// Reset regex state (global flag)
|
||||
CLAUDE_MD_PROMISE_PATTERN.lastIndex = 0;
|
||||
if (ralphConfig && ralphConfig.completionPromise) {
|
||||
session.ralphTracker.enable();
|
||||
session.ralphTracker.startLoop(
|
||||
ralphConfig.completionPromise,
|
||||
ralphConfig.maxIterations ?? undefined
|
||||
);
|
||||
|
||||
// Find all matches and return the first one
|
||||
const match = CLAUDE_MD_PROMISE_PATTERN.exec(content);
|
||||
if (match && match[1]) {
|
||||
const phrase = match[1].trim().toUpperCase();
|
||||
console.log(`[auto-detect] Found completion phrase in CLAUDE.md: ${phrase}`);
|
||||
return phrase;
|
||||
// Restore iteration count if available
|
||||
if (ralphConfig.iteration > 0) {
|
||||
// The tracker's cycleCount will be updated when we detect iteration patterns
|
||||
// in the terminal output, but we can set maxIterations now
|
||||
console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error(`[auto-detect] Error reading CLAUDE.md: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-configure inner loop tracker for a session based on CLAUDE.md
|
||||
*/
|
||||
function autoConfigureInnerLoop(session: Session, workingDir: string, broadcast: (event: string, data: unknown) => void): void {
|
||||
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}`);
|
||||
broadcast('session:ralphLoopUpdate', {
|
||||
sessionId: session.id,
|
||||
state: session.ralphTracker.loopState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: try CLAUDE.md
|
||||
const claudeMdPath = join(workingDir, 'CLAUDE.md');
|
||||
const completionPhrase = extractCompletionPhrase(claudeMdPath);
|
||||
|
||||
if (completionPhrase) {
|
||||
session.innerLoopTracker.enable();
|
||||
session.innerLoopTracker.startLoop(completionPhrase);
|
||||
console.log(`[auto-detect] Configured inner loop for session ${session.id} with phrase: ${completionPhrase}`);
|
||||
broadcast('session:innerLoopUpdate', {
|
||||
session.ralphTracker.enable();
|
||||
session.ralphTracker.startLoop(completionPhrase);
|
||||
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`);
|
||||
broadcast('session:ralphLoopUpdate', {
|
||||
sessionId: session.id,
|
||||
state: session.innerLoopTracker.loopState,
|
||||
state: session.ralphTracker.loopState,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -280,7 +275,7 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
return session.toDetailedState();
|
||||
@@ -291,18 +286,21 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
return {
|
||||
textOutput: session.textOutput,
|
||||
messages: session.messages,
|
||||
errorBuffer: session.errorBuffer,
|
||||
success: true,
|
||||
data: {
|
||||
textOutput: session.textOutput,
|
||||
messages: session.messages,
|
||||
errorBuffer: session.errorBuffer,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Get inner state (Ralph loop + todos) for a session
|
||||
this.app.get('/api/sessions/:id/inner-state', async (req) => {
|
||||
// Get Ralph state (Ralph loop + todos) for a session
|
||||
this.app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
@@ -313,15 +311,15 @@ export class WebServer extends EventEmitter {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
loop: session.innerLoopState,
|
||||
todos: session.innerTodos,
|
||||
todoStats: session.innerTodoStats,
|
||||
loop: session.ralphLoopState,
|
||||
todos: session.ralphTodos,
|
||||
todoStats: session.ralphTodoStats,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Configure inner loop (Ralph Wiggum) settings
|
||||
this.app.post('/api/sessions/:id/inner-config', async (req) => {
|
||||
// Configure Ralph (Ralph Wiggum) settings
|
||||
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { enabled, completionPhrase, maxIterations, maxTodos, todoExpirationMinutes, reset, disableAutoEnable } = req.body as {
|
||||
enabled?: boolean;
|
||||
@@ -335,53 +333,53 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { success: false, error: 'Session not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
// Handle reset first (before other config)
|
||||
if (reset) {
|
||||
if (reset === 'full') {
|
||||
session.innerLoopTracker.fullReset();
|
||||
session.ralphTracker.fullReset();
|
||||
} else {
|
||||
session.innerLoopTracker.reset();
|
||||
session.ralphTracker.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Configure auto-enable behavior
|
||||
if (disableAutoEnable !== undefined) {
|
||||
if (disableAutoEnable) {
|
||||
session.innerLoopTracker.disableAutoEnable();
|
||||
session.ralphTracker.disableAutoEnable();
|
||||
} else {
|
||||
session.innerLoopTracker.enableAutoEnable();
|
||||
session.ralphTracker.enableAutoEnable();
|
||||
}
|
||||
}
|
||||
|
||||
// Enable/disable the tracker
|
||||
if (enabled !== undefined) {
|
||||
if (enabled) {
|
||||
session.innerLoopTracker.enable();
|
||||
session.ralphTracker.enable();
|
||||
} else {
|
||||
session.innerLoopTracker.disable();
|
||||
session.ralphTracker.disable();
|
||||
}
|
||||
// Persist inner loop enabled state
|
||||
this.screenManager.updateInnerLoopEnabled(id, enabled);
|
||||
// Persist Ralph enabled state
|
||||
this.screenManager.updateRalphEnabled(id, enabled);
|
||||
}
|
||||
|
||||
// Configure the inner loop tracker
|
||||
// Configure the Ralph tracker
|
||||
if (completionPhrase !== undefined) {
|
||||
// Start loop with completion phrase to set it up for watching
|
||||
if (completionPhrase) {
|
||||
session.innerLoopTracker.startLoop(completionPhrase, maxIterations || undefined);
|
||||
session.ralphTracker.startLoop(completionPhrase, maxIterations || undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxIterations !== undefined) {
|
||||
session.innerLoopTracker.setMaxIterations(maxIterations || null);
|
||||
session.ralphTracker.setMaxIterations(maxIterations || null);
|
||||
}
|
||||
|
||||
// Store additional config on session for reference
|
||||
(session as any).innerConfig = {
|
||||
enabled: enabled ?? session.innerLoopTracker.enabled,
|
||||
(session as any).ralphConfig = {
|
||||
enabled: enabled ?? session.ralphTracker.enabled,
|
||||
completionPhrase: completionPhrase || '',
|
||||
maxIterations: maxIterations || 0,
|
||||
maxTodos: maxTodos || 50,
|
||||
@@ -389,9 +387,9 @@ export class WebServer extends EventEmitter {
|
||||
};
|
||||
|
||||
// Broadcast the update
|
||||
this.broadcast('session:innerLoopUpdate', {
|
||||
this.broadcast('session:ralphLoopUpdate', {
|
||||
sessionId: id,
|
||||
state: session.innerLoopState
|
||||
state: session.ralphLoopState
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
@@ -435,7 +433,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
try {
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting
|
||||
autoConfigureInnerLoop(session, session.workingDir, () => {});
|
||||
autoConfigureRalph(session, session.workingDir, () => {});
|
||||
|
||||
await session.startInteractive();
|
||||
this.broadcast('session:interactive', { id });
|
||||
@@ -512,7 +510,7 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
// Clean the buffer: remove junk before actual Claude content
|
||||
@@ -566,7 +564,7 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
// Create or get existing controller
|
||||
@@ -595,7 +593,7 @@ export class WebServer extends EventEmitter {
|
||||
const controller = this.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
return { error: 'Respawn controller not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found');
|
||||
}
|
||||
|
||||
controller.stop();
|
||||
@@ -615,7 +613,7 @@ export class WebServer extends EventEmitter {
|
||||
const controller = this.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
return { error: 'Respawn controller not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found');
|
||||
}
|
||||
|
||||
controller.updateConfig(config);
|
||||
@@ -635,16 +633,16 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
if (session.isBusy()) {
|
||||
return { error: 'Session is busy' };
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
}
|
||||
|
||||
try {
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting
|
||||
autoConfigureInnerLoop(session, session.workingDir, () => {});
|
||||
autoConfigureRalph(session, session.workingDir, () => {});
|
||||
|
||||
// Start interactive session
|
||||
await session.startInteractive();
|
||||
@@ -666,11 +664,13 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Interactive session with respawn started',
|
||||
respawnStatus: controller.getStatus(),
|
||||
data: {
|
||||
message: 'Interactive session with respawn started',
|
||||
respawnStatus: controller.getStatus(),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: getErrorMessage(err) };
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -681,12 +681,12 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
// Check if session is running (has a PID)
|
||||
if (!session.pid) {
|
||||
return { error: 'Session is not running. Start it first.' };
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.');
|
||||
}
|
||||
|
||||
// Stop existing controller if any
|
||||
@@ -725,7 +725,15 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
if (body.enabled === undefined) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required');
|
||||
}
|
||||
|
||||
if (body.threshold !== undefined && (typeof body.threshold !== 'number' || body.threshold < 0)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'threshold must be a positive number');
|
||||
}
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
@@ -733,9 +741,11 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
autoClear: {
|
||||
enabled: session.autoClearEnabled,
|
||||
threshold: session.autoClearThreshold,
|
||||
data: {
|
||||
autoClear: {
|
||||
enabled: session.autoClearEnabled,
|
||||
threshold: session.autoClearThreshold,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -747,7 +757,15 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
if (body.enabled === undefined) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required');
|
||||
}
|
||||
|
||||
if (body.threshold !== undefined && (typeof body.threshold !== 'number' || body.threshold < 0)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'threshold must be a positive number');
|
||||
}
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
@@ -755,10 +773,12 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
autoCompact: {
|
||||
enabled: session.autoCompactEnabled,
|
||||
threshold: session.autoCompactThreshold,
|
||||
prompt: session.autoCompactPrompt,
|
||||
data: {
|
||||
autoCompact: {
|
||||
enabled: session.autoCompactEnabled,
|
||||
threshold: session.autoCompactThreshold,
|
||||
prompt: session.autoCompactPrompt,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -767,10 +787,14 @@ export class WebServer extends EventEmitter {
|
||||
this.app.post('/api/run', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` };
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
|
||||
}
|
||||
|
||||
const { prompt, workingDir } = req.body as QuickRunRequest;
|
||||
|
||||
if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
||||
}
|
||||
const dir = workingDir || process.cwd();
|
||||
|
||||
const session = new Session({ workingDir: dir });
|
||||
@@ -1052,7 +1076,7 @@ export class WebServer extends EventEmitter {
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
// so the initial state already has the phrase configured
|
||||
if (mode === 'claude') {
|
||||
autoConfigureInnerLoop(session, casePath, () => {}); // no broadcast yet
|
||||
autoConfigureRalph(session, casePath, () => {}); // no broadcast yet
|
||||
}
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
@@ -1240,20 +1264,20 @@ export class WebServer extends EventEmitter {
|
||||
this.outputBatches.delete(sessionId);
|
||||
this.taskUpdateBatches.delete(sessionId);
|
||||
|
||||
// Reset inner loop tracker on the session before cleanup
|
||||
// Reset Ralph tracker on the session before cleanup
|
||||
if (session) {
|
||||
session.innerLoopTracker.fullReset();
|
||||
session.ralphTracker.fullReset();
|
||||
}
|
||||
|
||||
// Clear inner state from store
|
||||
this.store.removeInnerState(sessionId);
|
||||
// Clear Ralph state from store
|
||||
this.store.removeRalphState(sessionId);
|
||||
|
||||
// Broadcast inner loop cleared to update UI
|
||||
this.broadcast('session:innerLoopUpdate', {
|
||||
// Broadcast Ralph cleared to update UI
|
||||
this.broadcast('session:ralphLoopUpdate', {
|
||||
sessionId,
|
||||
state: { enabled: false, active: false, completionPhrase: null, startedAt: null, cycleCount: 0, maxIterations: null, lastActivity: Date.now(), elapsedHours: null }
|
||||
});
|
||||
this.broadcast('session:innerTodoUpdate', {
|
||||
this.broadcast('session:ralphTodoUpdate', {
|
||||
sessionId,
|
||||
todos: [],
|
||||
stats: { total: 0, pending: 0, inProgress: 0, completed: 0 }
|
||||
@@ -1361,21 +1385,21 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
});
|
||||
|
||||
// Inner loop tracking events
|
||||
session.on('innerLoopUpdate', (state: InnerLoopState) => {
|
||||
this.broadcast('session:innerLoopUpdate', { sessionId: session.id, state });
|
||||
// Persist inner state
|
||||
this.store.updateInnerState(session.id, { loop: state });
|
||||
// Ralph tracking events
|
||||
session.on('ralphLoopUpdate', (state: RalphTrackerState) => {
|
||||
this.broadcast('session:ralphLoopUpdate', { sessionId: session.id, state });
|
||||
// Persist Ralph state
|
||||
this.store.updateRalphState(session.id, { loop: state });
|
||||
});
|
||||
|
||||
session.on('innerTodoUpdate', (todos: InnerTodoItem[]) => {
|
||||
this.broadcast('session:innerTodoUpdate', { sessionId: session.id, todos });
|
||||
// Persist inner state
|
||||
this.store.updateInnerState(session.id, { todos });
|
||||
session.on('ralphTodoUpdate', (todos: RalphTodoItem[]) => {
|
||||
this.broadcast('session:ralphTodoUpdate', { sessionId: session.id, todos });
|
||||
// Persist Ralph state
|
||||
this.store.updateRalphState(session.id, { todos });
|
||||
});
|
||||
|
||||
session.on('innerCompletionDetected', (phrase: string) => {
|
||||
this.broadcast('session:innerCompletionDetected', { sessionId: session.id, phrase });
|
||||
session.on('ralphCompletionDetected', (phrase: string) => {
|
||||
this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1788,25 +1812,25 @@ export class WebServer extends EventEmitter {
|
||||
this.sessions.set(session.id, session);
|
||||
this.setupSessionListeners(session);
|
||||
|
||||
// Restore inner loop tracking state (Ralph Wiggum settings) if it was saved
|
||||
const innerState = this.store.getInnerState(screen.sessionId);
|
||||
if (innerState) {
|
||||
session.innerLoopTracker.restoreState(innerState.loop, innerState.todos);
|
||||
console.log(`[Server] Restored inner loop state for session ${session.id} (enabled: ${innerState.loop.enabled})`);
|
||||
// Restore Ralph tracking state (Ralph Wiggum settings) if it was saved
|
||||
const ralphState = this.store.getRalphState(screen.sessionId);
|
||||
if (ralphState) {
|
||||
session.ralphTracker.restoreState(ralphState.loop, ralphState.todos);
|
||||
console.log(`[Server] Restored Ralph state for session ${session.id} (enabled: ${ralphState.loop.enabled})`);
|
||||
}
|
||||
// Also check screen.innerLoopEnabled as a fallback
|
||||
if (screen.innerLoopEnabled && !session.innerLoopTracker.enabled) {
|
||||
session.innerLoopTracker.enable();
|
||||
console.log(`[Server] Enabled inner loop tracker for session ${session.id} from screen config`);
|
||||
// Also check screen.ralphEnabled as a fallback
|
||||
if (screen.ralphEnabled && !session.ralphTracker.enabled) {
|
||||
session.ralphTracker.enable();
|
||||
console.log(`[Server] Enabled Ralph tracker for session ${session.id} from screen config`);
|
||||
}
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md if not already set
|
||||
if (!session.innerLoopTracker.loopState.completionPhrase) {
|
||||
if (!session.ralphTracker.loopState.completionPhrase) {
|
||||
const claudeMdPath = join(session.workingDir, 'CLAUDE.md');
|
||||
const completionPhrase = extractCompletionPhrase(claudeMdPath);
|
||||
if (completionPhrase) {
|
||||
session.innerLoopTracker.enable();
|
||||
session.innerLoopTracker.startLoop(completionPhrase);
|
||||
session.ralphTracker.enable();
|
||||
session.ralphTracker.startLoop(completionPhrase);
|
||||
console.log(`[Server] Auto-detected completion phrase for session ${session.id}: ${completionPhrase}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user