/** * @fileoverview Ralph Loop Wizard — multi-step modal for configuring autonomous task loops. * * Extends CodemanApp.prototype with wizard methods for the Ralph Loop setup flow: * Step 1: Task description, completion phrase, iteration limit, case selection * Step 2: AI-powered plan generation (optional) with research agent → planner agent pipeline * Step 3: Respawn configuration (idle timeout, kickstart prompt, auto-clear/init) * Step 4: Review and launch * * Features: * - Plan generation via POST /api/sessions/:id/plan/generate with SSE progress streaming * - Existing @fix_plan.md detection and reuse * - Plan detail level selection (brief/detailed/comprehensive) * - Case selector population from /api/cases * - Focus trap for modal accessibility * - Abort controller for cancelling in-flight plan generation * * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp class must be defined) * @dependency keyboard-accessory.js (FocusTrap class for modal focus management) * @dependency constants.js (escapeHtml) * @loadorder 13 of 15 — loaded after session-ui.js, before api-client.js */ // ═══════════════════════════════════════════════════════════════ // Ralph Loop Wizard // ═══════════════════════════════════════════════════════════════ Object.assign(CodemanApp.prototype, { showRalphWizard() { // Reset wizard state this.ralphWizardStep = 1; this.ralphWizardConfig = { taskDescription: '', completionPhrase: 'COMPLETE', maxIterations: 10, caseName: document.getElementById('quickStartCase')?.value || 'testcase', enableRespawn: false, generatedPlan: null, planGenerated: false, skipPlanGeneration: false, planDetailLevel: 'detailed', existingPlan: null, useExistingPlan: false, }; // Reset UI document.getElementById('ralphTaskDescription').value = ''; document.getElementById('ralphCompletionPhrase').value = 'COMPLETE'; this.selectIterationPreset(10); const autoStartStep2El = document.getElementById('ralphAutoStartStep2'); if (autoStartStep2El) autoStartStep2El.checked = false; // Populate case selector this.populateRalphCaseSelector(); // Reset plan generation UI this.resetPlanGenerationUI(); // Check for existing @fix_plan.md in selected case this.checkExistingFixPlan(); // Show wizard modal this.updateRalphWizardUI(); const modal = document.getElementById('ralphWizardModal'); modal.classList.add('active'); // Activate focus trap this.activeFocusTrap = new FocusTrap(modal); this.activeFocusTrap.previouslyFocused = document.activeElement; modal.addEventListener('keydown', this.activeFocusTrap.boundHandleKeydown); document.getElementById('ralphTaskDescription').focus(); }, closeRalphWizard() { const modal = document.getElementById('ralphWizardModal'); modal?.classList.remove('active'); // Deactivate focus trap and restore focus if (this.activeFocusTrap) { this.activeFocusTrap.deactivate(); this.activeFocusTrap = null; } // Cancel any in-flight plan generation if (this.activePlanOrchestratorId) { fetch('/api/cancel-plan-generation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orchestratorId: this.activePlanOrchestratorId }), }).catch(err => console.error('[Wizard Close] Failed to cancel plan:', err)); this.activePlanOrchestratorId = null; } // Abort any in-flight fetch request if (this.planGenerationAbortController) { this.planGenerationAbortController.abort(); this.planGenerationAbortController = null; } this._planProgressHandler = null; // Clear plan loading timers if (this.planLoadingTimer) { clearInterval(this.planLoadingTimer); this.planLoadingTimer = null; } if (this.planPhaseTimer) { clearTimeout(this.planPhaseTimer); this.planPhaseTimer = null; } this.planGenerationStopped = true; // Close all plan subagent windows this.closePlanSubagentWindows(); // Update monitor panel this.renderMonitorPlanAgents(); this.updateConnectionLines(); }, populateRalphCaseSelector() { const select = document.getElementById('ralphCaseSelect'); const quickStartSelect = document.getElementById('quickStartCase'); if (quickStartSelect && select) { select.innerHTML = quickStartSelect.innerHTML; select.value = this.ralphWizardConfig.caseName; } }, selectIterationPreset(iterations) { this.ralphWizardConfig.maxIterations = iterations; // Update button states document.querySelectorAll('.iteration-preset-btn').forEach(btn => { const btnIterations = parseInt(btn.dataset.iterations); btn.classList.toggle('active', btnIterations === iterations); }); }, // Check for existing @fix_plan.md in the selected case async checkExistingFixPlan() { const caseName = this.ralphWizardConfig.caseName; if (!caseName) return; try { const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/fix-plan`); const data = await res.json(); if (data.success && data.exists && data.todos?.length > 0) { this.ralphWizardConfig.existingPlan = { todos: data.todos, stats: data.stats, content: data.content, }; this.updateExistingPlanUI(); } else { this.ralphWizardConfig.existingPlan = null; this.updateExistingPlanUI(); } } catch (err) { console.error('Failed to check for existing plan:', err); this.ralphWizardConfig.existingPlan = null; } }, // Called when case selector changes onRalphCaseChange() { const caseName = document.getElementById('ralphCaseSelect')?.value; if (caseName) { this.ralphWizardConfig.caseName = caseName; this.ralphWizardConfig.existingPlan = null; this.ralphWizardConfig.useExistingPlan = false; this.checkExistingFixPlan(); } }, // Update UI to show existing plan indicator updateExistingPlanUI() { const existingPlanBadge = document.getElementById('existingPlanBadge'); const existingPlanSection = document.getElementById('existingPlanSection'); const plan = this.ralphWizardConfig.existingPlan; if (existingPlanBadge) { if (plan) { const pending = plan.stats?.pending || 0; const total = plan.stats?.total || 0; existingPlanBadge.textContent = `${pending}/${total} tasks remaining`; existingPlanBadge.style.display = ''; } else { existingPlanBadge.style.display = 'none'; } } if (existingPlanSection) { if (plan) { const pending = plan.stats?.pending || 0; const completed = plan.stats?.completed || 0; const total = plan.stats?.total || 0; existingPlanSection.innerHTML = `
$1');
preview.innerHTML = highlightedPrompt;
// Update summary
document.getElementById('summaryPhrase').textContent = config.completionPhrase;
document.getElementById('summaryIterations').textContent =
config.maxIterations === 0 ? 'Unlimited' : config.maxIterations;
document.getElementById('summaryCase').textContent = config.caseName;
// Show plan status in summary if plan was generated
const planSummary = document.getElementById('summaryPlan');
if (planSummary) {
if (config.generatedPlan && config.generatedPlan.length > 0) {
const enabledCount = config.generatedPlan.filter(i => i.enabled).length;
planSummary.textContent = `${enabledCount} item${enabledCount !== 1 ? 's' : ''}`;
planSummary.parentElement.style.display = '';
} else {
planSummary.parentElement.style.display = 'none';
}
}
},
// ═══════════════════════════════════════════════════════════════
// Plan Generation
// ═══════════════════════════════════════════════════════════════
resetPlanGenerationUI() {
// Hide all plan generation states
document.getElementById('existingPlanSection')?.classList.add('hidden');
document.getElementById('planGenerationLoading')?.classList.add('hidden');
document.getElementById('planGenerationError')?.classList.add('hidden');
document.getElementById('planEditor')?.classList.add('hidden');
// Reset spinner visibility (in case it was hidden after "Done!")
const spinnerEl = document.querySelector('.plan-spinner');
if (spinnerEl) spinnerEl.style.display = '';
// Reset stopped indicator
const stoppedIndicator = document.getElementById('planStoppedIndicator');
if (stoppedIndicator) stoppedIndicator.style.display = 'none';
// Reset existing plan badge
const badge = document.getElementById('existingPlanBadge');
if (badge) badge.style.display = 'none';
},
async generatePlan() {
const config = this.ralphWizardConfig;
const isDetailed = config.planDetailLevel === 'detailed';
// Stop any existing generation first
this.stopPlanGeneration();
// Close old plan subagent windows and clear their state
this.closePlanSubagentWindows();
// Reset stopped flag to allow new SSE events
this.planGenerationStopped = false;
// Create abort controller for this generation
this.planGenerationAbortController = new AbortController();
// Show loading state, hide other sections
document.getElementById('existingPlanSection')?.classList.add('hidden');
document.getElementById('planGenerationError')?.classList.add('hidden');
document.getElementById('planEditor')?.classList.add('hidden');
document.getElementById('planGenerationLoading')?.classList.remove('hidden');
// Different phases for detailed vs standard generation
const standardPhases = [
{ time: 0, title: 'Starting Opus 4.5...', hint: 'Initializing deep reasoning model' },
{ time: 3, title: 'Analyzing task requirements...', hint: 'Understanding the scope and complexity' },
{ time: 8, title: 'Identifying components...', hint: 'Breaking down into modules and features' },
{ time: 15, title: 'Planning TDD approach...', hint: 'Designing test-first implementation strategy' },
{ time: 25, title: 'Generating implementation steps...', hint: 'Creating detailed action items with tests' },
{ time: 40, title: 'Adding verification checkpoints...', hint: 'Ensuring each phase has validation' },
{ time: 55, title: 'Reviewing for completeness...', hint: 'Checking all requirements are covered' },
{ time: 70, title: 'Finalizing plan...', hint: 'Organizing and prioritizing steps' },
{ time: 90, title: 'Still working...', hint: 'Complex tasks take longer - hang tight!' },
];
const detailedPhases = [
{ time: 0, title: 'Starting research agent...', hint: 'Gathering external resources and codebase context' },
{ time: 30, title: 'Research agent working...', hint: 'Searching docs, GitHub repos, and analyzing codebase' },
{ time: 60, title: 'Research continuing...', hint: 'Web search and codebase exploration in progress' },
{ time: 120, title: 'Research agent deep diving...', hint: 'Complex tasks require thorough research' },
{ time: 180, title: 'Research almost complete...', hint: 'Compiling findings and recommendations' },
{ time: 300, title: 'Spawning analysis subagents...', hint: 'Starting 4 specialist agents in parallel' },
{ time: 330, title: 'Subagents analyzing...', hint: 'Requirements, Architecture, Testing, Risk analysts working' },
{ time: 400, title: 'Subagents completing...', hint: 'Collecting analysis results' },
{ time: 450, title: 'Synthesizing results...', hint: 'Merging and deduplicating items' },
{ time: 500, title: 'Running verification...', hint: 'Quality assurance and priority assignment' },
{ time: 550, title: 'Optimizing execution...', hint: 'Planning parallelization for Claude Code' },
{ time: 600, title: 'Final review...', hint: 'Holistic validation and gap detection' },
{ time: 660, title: 'Still working...', hint: 'Complex tasks take longer - hang tight!' },
];
const phases = isDetailed ? detailedPhases : standardPhases;
// Start elapsed time and phase display
this.planLoadingStartTime = Date.now();
const timeEl = document.getElementById('planLoadingTime');
const titleEl = document.getElementById('planLoadingTitle');
const hintEl = document.getElementById('planLoadingHint');
if (timeEl) timeEl.textContent = '0s';
if (titleEl) titleEl.textContent = phases[0].title;
if (hintEl) hintEl.textContent = phases[0].hint;
let currentPhaseIndex = 0;
this.planLoadingTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - this.planLoadingStartTime) / 1000);
if (timeEl) timeEl.textContent = `${elapsed}s`;
// Update phase based on elapsed time
for (let i = phases.length - 1; i >= 0; i--) {
if (elapsed >= phases[i].time && i > currentPhaseIndex) {
currentPhaseIndex = i;
if (titleEl) titleEl.textContent = phases[i].title;
if (hintEl) hintEl.textContent = phases[i].hint;
break;
}
}
}, 1000);
// Listen for real-time progress updates from detailed generation
const handlePlanProgress = (event) => {
if (event.type === 'plan:progress' && event.data) {
const titleEl = document.getElementById('planLoadingTitle');
const hintEl = document.getElementById('planLoadingHint');
if (titleEl && event.data.phase) {
const phaseLabels = {
'research': 'Research agent working...',
'parallel-analysis': 'Spawning analysis subagents...',
'subagent': event.data.detail || 'Subagent working...',
'synthesis': 'Synthesizing results...',
'verification': 'Running verification...',
'review-injection': 'Adding review tasks...',
'execution-optimization': 'Optimizing for Claude Code...',
'final-review': 'Running final review...',
};
titleEl.textContent = phaseLabels[event.data.phase] || event.data.phase;
}
if (hintEl && event.data.detail) {
hintEl.textContent = event.data.detail;
}
}
};
// Add SSE listener for detailed mode progress
if (isDetailed) {
this._planProgressHandler = handlePlanProgress;
}
try {
// Use different endpoint for detailed mode
const endpoint = isDetailed ? '/api/generate-plan-detailed' : '/api/generate-plan';
const body = isDetailed
? { taskDescription: config.taskDescription, caseName: config.caseName }
: { taskDescription: config.taskDescription, detailLevel: config.planDetailLevel };
// Retry logic for network errors
const maxRetries = 3;
let lastError = null;
let data = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: this.planGenerationAbortController?.signal,
});
data = await res.json();
break; // Success, exit retry loop
} catch (fetchErr) {
lastError = fetchErr;
// Don't retry if aborted
if (fetchErr.name === 'AbortError') throw fetchErr;
// Network error - retry with exponential backoff
console.warn(`[Plan] Fetch attempt ${attempt + 1} failed:`, fetchErr.message);
if (attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
const titleEl = document.getElementById('planLoadingTitle');
const hintEl = document.getElementById('planLoadingHint');
if (titleEl) titleEl.textContent = 'Connection lost, retrying...';
if (hintEl) hintEl.textContent = `Attempt ${attempt + 2} of ${maxRetries} in ${delay / 1000}s`;
await new Promise(r => setTimeout(r, delay));
}
}
}
if (!data) {
throw lastError || new Error('Failed to fetch after retries');
}
// Stop timer
if (this.planLoadingTimer) {
clearInterval(this.planLoadingTimer);
this.planLoadingTimer = null;
}
// Remove progress handler
this._planProgressHandler = null;
if (!data.success) {
this.showPlanError(data.error || 'Failed to generate plan');
return;
}
if (!data.data?.items || data.data.items.length === 0) {
this.showPlanError('No plan items generated. Try adding more detail to your task.');
return;
}
// Show "Done!" with quality info for detailed mode
const doneTitle = document.getElementById('planLoadingTitle');
const doneHint = document.getElementById('planLoadingHint');
const spinnerEl = document.querySelector('.plan-spinner');
if (doneTitle) doneTitle.textContent = 'Done!';
if (doneHint) {
if (isDetailed && data.data.metadata?.qualityScore) {
const quality = Math.round(data.data.metadata.qualityScore * 100);
doneHint.textContent = `Generated ${data.data.items.length} steps (Quality: ${quality}%)`;
} else {
doneHint.textContent = `Generated ${data.data.items.length} steps`;
}
}
if (spinnerEl) spinnerEl.style.display = 'none';
// Brief pause to show "Done!" before showing editor
await new Promise(r => setTimeout(r, 500));
// Store plan with enabled state and IDs
config.generatedPlan = data.data.items.map((item, idx) => ({
...item,
enabled: true,
id: `plan-${Date.now()}-${idx}`,
}));
config.planGenerated = true;
config.skipPlanGeneration = false;
config.planCost = data.data.costUsd || 0;
// Store metadata for detailed mode
if (isDetailed && data.data.metadata) {
config.planMetadata = data.data.metadata;
}
// Show editor and update detail buttons
this.renderPlanChecklist();
this.updateDetailLevelButtons();
// Check for auto-start
if (this.ralphWizardConfig.autoStart) {
console.log('[RalphWizard] Auto-start enabled, starting loop automatically...');
this.showToast('Plan complete! Auto-starting Ralph Loop...', 'success');
// Small delay to let user see the plan briefly
await new Promise(r => setTimeout(r, 1500));
this.startRalphLoop();
}
} catch (err) {
// Stop timer
if (this.planLoadingTimer) {
clearInterval(this.planLoadingTimer);
this.planLoadingTimer = null;
}
// Remove progress handler
this._planProgressHandler = null;
// Ignore abort errors (user cancelled, e.g., clicked "Use Existing Plan")
if (err.name === 'AbortError') {
console.log('Plan generation aborted by user');
return;
}
console.error('Plan generation failed:', err);
this.showPlanError('Network error: ' + err.message);
}
},
setPlanDetail(level) {
const previousLevel = this.ralphWizardConfig.planDetailLevel;
this.ralphWizardConfig.planDetailLevel = level;
this.updateDetailLevelButtons();
// If plan was already generated and level changed, automatically regenerate
if (this.ralphWizardConfig.planGenerated && previousLevel !== level) {
const modeLabel = level === 'detailed' ? 'Enhanced (Multi-Agent)' : 'Standard';
console.log(`[Ralph Wizard] Plan mode changed to ${modeLabel}, regenerating...`);
// Clear current plan and regenerate
this.ralphWizardConfig.generatedPlan = null;
this.ralphWizardConfig.planGenerated = false;
this.ralphWizardConfig.planMetadata = null;
// Trigger regeneration with visual feedback
this.generatePlan();
}
},
updateDetailLevelButtons() {
const level = this.ralphWizardConfig.planDetailLevel;
document.querySelectorAll('.plan-detail-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.detail === level);
});
},
showPlanError(message) {
document.getElementById('planGenerationLoading')?.classList.add('hidden');
document.getElementById('planEditor')?.classList.add('hidden');
const errorEl = document.getElementById('planGenerationError');
const msgEl = document.getElementById('planErrorMsg');
const stoppedIndicator = document.getElementById('planStoppedIndicator');
// Hide the stopped indicator for real errors, show error message
if (stoppedIndicator) stoppedIndicator.style.display = 'none';
if (msgEl) msgEl.textContent = message;
errorEl?.classList.remove('hidden');
},
renderPlanChecklist() {
document.getElementById('planGenerationLoading')?.classList.add('hidden');
document.getElementById('planGenerationError')?.classList.add('hidden');
document.getElementById('planEditor')?.classList.remove('hidden');
// Close plan subagent windows since generation is complete
this.closePlanSubagentWindows();
const list = document.getElementById('planItemsList');
if (!list) return;
list.innerHTML = '';
const items = this.ralphWizardConfig.generatedPlan || [];
const cost = this.ralphWizardConfig.planCost || 0;
// Update stats
const statsEl = document.getElementById('planStats');
if (statsEl) {
const enabledCount = items.filter(i => i.enabled).length;
statsEl.textContent = `${enabledCount}/${items.length} steps · $${cost.toFixed(3)}`;
}
// Render read-only checklist with DocumentFragment
const fragment = document.createDocumentFragment();
items.forEach((item, index) => {
const row = document.createElement('div');
let className = 'plan-item';
if (item.priority) className += ` priority-${item.priority.toLowerCase()}`;
if (!item.enabled) className += ' disabled';
row.className = className;
row.innerHTML = `
${item.priority ? `${item.priority}` : ''}
${escapeHtml(item.content)}
`;
fragment.appendChild(row);
});
list.appendChild(fragment);
},
togglePlanItem(index) {
const plan = this.ralphWizardConfig.generatedPlan;
if (plan && plan[index]) {
plan[index].enabled = !plan[index].enabled;
this.renderPlanChecklist();
}
},
async cancelPlanGeneration() {
this.stopPlanGeneration();
this.planGenerationStopped = true; // Ignore future SSE events
// Call the cancel API to stop server-side processing
if (this.activePlanOrchestratorId) {
try {
console.log('[Cancel] Sending cancel request for', this.activePlanOrchestratorId);
await fetch('/api/cancel-plan-generation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orchestratorId: this.activePlanOrchestratorId }),
});
this.activePlanOrchestratorId = null;
} catch (err) {
console.error('[Cancel] Failed to cancel plan generation:', err);
}
}
this.showToast('Plan generation stopped', 'info');
// Allow user to proceed without a plan by clicking Next
this.ralphWizardConfig.skipPlanGeneration = true;
// Show stopped state with clear visual indicator
const errorEl = document.getElementById('planGenerationError');
const msgEl = document.getElementById('planErrorMsg');
const stoppedIndicator = document.getElementById('planStoppedIndicator');
// Show the stopped indicator, hide error message since this was intentional
if (stoppedIndicator) stoppedIndicator.style.display = 'flex';
if (msgEl) msgEl.textContent = '';
// Hide spinner immediately and show stopped state
document.getElementById('planGenerationLoading')?.classList.add('hidden');
errorEl?.classList.remove('hidden');
// Close all plan subagent windows
this.closePlanSubagentWindows();
},
// ═══════════════════════════════════════════════════════════════
// Plan Subagent Windows
// ═══════════════════════════════════════════════════════════════
handlePlanSubagentEvent(event) {
if (this.planGenerationStopped) return;
const { type, agentId, agentType, model, status, detail, itemCount, durationMs, error } = event;
if (type === 'started') {
this.createPlanSubagentWindow(agentId, agentType, model, detail);
} else if (type === 'completed' || type === 'failed') {
this.updatePlanSubagentWindow(agentId, status, itemCount, durationMs, error);
} else if (type === 'progress') {
const windowData = this.planSubagents.get(agentId);
if (windowData?.element) {
const detailEl = windowData.element.querySelector('.plan-subagent-detail');
if (detailEl) detailEl.textContent = detail || '';
}
}
this.renderMonitorPlanAgents();
},
/**
* Position a plan subagent window to the left or right of the wizard.
* Research goes left, planner goes right.
*/
_positionPlanSubagentWindow(agentType) {
const wizardModal = document.getElementById('ralphWizardModal');
const wizardContent = wizardModal?.querySelector('.modal-content');
const wizardRect = wizardContent?.getBoundingClientRect();
const gap = 20;
const windowWidth = 280;
if (!wizardRect) {
const offset = this.planSubagents.size * 30;
return { x: window.innerWidth - windowWidth - 50 + offset, y: 120 + offset };
}
const side = agentType === 'research' ? 'left' : 'right';
let x = side === 'left'
? wizardRect.left - windowWidth - gap
: wizardRect.right + gap;
x = Math.max(10, Math.min(x, window.innerWidth - windowWidth - 10));
const y = Math.max(60, Math.min(wizardRect.top, window.innerHeight - 120));
return { x, y };
},
createPlanSubagentWindow(agentId, agentType, model, detail) {
if (this.planSubagents.has(agentId)) return;
const { x, y } = this._positionPlanSubagentWindow(agentType);
const win = document.createElement('div');
win.className = 'plan-subagent-window';
win.id = `plan-subagent-${agentId}`;
win.style.left = `${x}px`;
win.style.top = `${y}px`;
win.style.zIndex = ++this.planSubagentWindowZIndex;
const typeLabels = { research: 'Research Agent', planner: 'Planner' };
const typeIcons = { research: '🔬', planner: '📋' };
win.innerHTML = `