mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +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:
+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