fix: Ralph Loop wizard screen creation and session idle detection

- Fix screen creation failing when using cpulimit/nice (env vars were passed
  as command args instead of shell variables)
- Fix session status never becoming 'idle' after startInteractive()
- Add automatic state file cleanup on startup (removes orphaned sessions)
- Add /api/cleanup-state endpoint for manual cleanup
- Make frontend session readiness check more robust (20 attempts, multiple checks)
- Add fallback to direct PTY write if writeViaScreen fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-27 11:02:02 +01:00
co-authored by Claude Opus 4.5
parent 2d8597a66f
commit f92d009034
9 changed files with 649 additions and 104 deletions
+281 -40
View File
@@ -2467,6 +2467,9 @@ class ClaudemanApp {
planGenerated: false,
skipPlanGeneration: false,
planDetailLevel: 'standard',
// Existing plan detection
existingPlan: null, // { todos, stats, content } from @fix_plan.md
useExistingPlan: false, // User chose to use existing plan
};
// Reset UI
@@ -2480,6 +2483,9 @@ class ClaudemanApp {
// Reset plan generation UI
this.resetPlanGenerationUI();
// Check for existing @fix_plan.md in selected case
this.checkExistingFixPlan();
// Show wizard modal
this.updateRalphWizardUI();
document.getElementById('ralphWizardModal').classList.add('active');
@@ -2510,6 +2516,121 @@ class ClaudemanApp {
});
}
// 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 = `
<div class="existing-plan-card">
<div class="existing-plan-header">
<span class="existing-plan-icon">📋</span>
<span>Existing @fix_plan.md found</span>
</div>
<div class="existing-plan-stats">
<span class="stat pending">${pending} pending</span>
<span class="stat completed">${completed} completed</span>
<span class="stat total">${total} total</span>
</div>
<div class="existing-plan-actions">
<button class="btn-toolbar btn-primary btn-sm" onclick="app.useExistingPlan()">
Use Existing Plan
</button>
<button class="btn-toolbar btn-sm" onclick="app.generateNewPlan()">
Generate New
</button>
</div>
</div>
`;
existingPlanSection.classList.remove('hidden');
} else {
existingPlanSection.classList.add('hidden');
}
}
}
// Use the existing @fix_plan.md
useExistingPlan() {
const plan = this.ralphWizardConfig.existingPlan;
if (!plan) return;
// Convert existing todos to generatedPlan format (only pending items)
const pendingTodos = plan.todos.filter(t => t.status === 'pending' || t.status === 'in_progress');
this.ralphWizardConfig.generatedPlan = pendingTodos.map((todo, idx) => ({
content: todo.content,
priority: todo.priority,
enabled: true,
id: `existing-${Date.now()}-${idx}`,
}));
this.ralphWizardConfig.planGenerated = true;
this.ralphWizardConfig.useExistingPlan = true;
this.ralphWizardConfig.planCost = 0; // No cost for existing plan
this.renderPlanEditor();
this.updateDetailLevelButtons();
}
// Generate a new plan (ignore existing)
generateNewPlan() {
this.ralphWizardConfig.useExistingPlan = false;
document.getElementById('existingPlanSection')?.classList.add('hidden');
this.generatePlan();
}
ralphWizardNext() {
if (this.ralphWizardStep === 1) {
// Validate step 1
@@ -2528,12 +2649,17 @@ class ClaudemanApp {
this.ralphWizardConfig.completionPhrase = completionPhrase.toUpperCase();
this.ralphWizardConfig.caseName = caseName;
// Move to step 2 (plan generation) and auto-start
// Move to step 2 (plan generation)
this.ralphWizardStep = 2;
this.updateRalphWizardUI();
// Auto-start plan generation
this.generatePlan();
// If there's an existing plan, show the option to use it
// Otherwise auto-start plan generation
if (this.ralphWizardConfig.existingPlan) {
this.updateExistingPlanUI();
} else {
this.generatePlan();
}
} else if (this.ralphWizardStep === 2) {
// Must have generated or skipped plan
if (!this.ralphWizardConfig.planGenerated && !this.ralphWizardConfig.skipPlanGeneration) {
@@ -2584,22 +2710,31 @@ class ClaudemanApp {
updateRalphPromptPreview() {
const config = this.ralphWizardConfig;
const preview = document.getElementById('ralphPromptPreview');
const hasPlan = config.generatedPlan && config.generatedPlan.filter(i => i.enabled).length > 0;
// Build the formatted prompt
// Build the formatted prompt (abbreviated for preview)
let prompt = config.taskDescription;
// Add completion instructions
prompt += '\n\n---\n\n';
prompt += '## Completion Criteria\n\n';
prompt += 'When ALL requirements above are complete:\n';
prompt += '1. Run all tests to verify\n';
prompt += '2. Commit your changes\n';
prompt += `3. Output: <promise>${config.completionPhrase}</promise>\n`;
if (hasPlan) {
prompt += '## Task Plan\n';
prompt += '📋 @fix_plan.md will be created with your task items\n\n';
}
prompt += '## Iteration Protocol\n';
prompt += '• Check previous work • Make progress • Commit changes\n\n';
prompt += '## Completion Criteria\n';
prompt += `Output \`<promise>${config.completionPhrase}</promise>\` when done\n\n`;
prompt += '## If Stuck\n';
prompt += 'Output `<promise>BLOCKED</promise>` with explanation';
// Show preview with highlighting
const highlightedPrompt = prompt
.replace(/<promise>/g, '<span class="preview-highlight">&lt;promise&gt;')
.replace(/<\/promise>/g, '&lt;/promise&gt;</span>');
.replace(/<\/promise>/g, '&lt;/promise&gt;</span>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
preview.innerHTML = highlightedPrompt;
@@ -2625,11 +2760,19 @@ class ClaudemanApp {
// ========== Plan Generation ==========
resetPlanGenerationUI() {
// Show initial controls, hide other states
document.getElementById('planGenerationControls')?.classList.remove('hidden');
// 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 existing plan badge
const badge = document.getElementById('existingPlanBadge');
if (badge) badge.style.display = 'none';
}
async generatePlan() {
@@ -2645,7 +2788,8 @@ class ClaudemanApp {
this.planPhaseTimer = null;
}
// Show loading state
// 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');
@@ -2689,21 +2833,12 @@ class ClaudemanApp {
}
}, 1000);
// Determine max items based on detail level
const detailConfig = {
brief: { maxItems: 10 },
standard: { maxItems: 15 },
detailed: { maxItems: 20 },
};
const { maxItems } = detailConfig[config.planDetailLevel] || detailConfig.standard;
try {
const res = await fetch('/api/generate-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskDescription: config.taskDescription,
maxItems,
detailLevel: config.planDetailLevel,
}),
});
@@ -2726,6 +2861,17 @@ class ClaudemanApp {
return;
}
// Show "Done!" briefly before transitioning
const titleEl = document.getElementById('planLoadingTitle');
const hintEl = document.getElementById('planLoadingHint');
const spinnerEl = document.querySelector('.plan-spinner');
if (titleEl) titleEl.textContent = 'Done!';
if (hintEl) hintEl.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,
@@ -2734,6 +2880,7 @@ class ClaudemanApp {
}));
config.planGenerated = true;
config.skipPlanGeneration = false;
config.planCost = data.data.costUsd || 0;
// Show editor and update detail buttons
this.renderPlanEditor();
@@ -2784,6 +2931,13 @@ class ClaudemanApp {
list.innerHTML = '';
const items = this.ralphWizardConfig.generatedPlan || [];
const cost = this.ralphWizardConfig.planCost || 0;
// Update header with item count and cost
const statsEl = document.getElementById('planStats');
if (statsEl) {
statsEl.textContent = `${items.length} steps · $${cost.toFixed(3)}`;
}
items.forEach((item, index) => {
const row = this.renderPlanItem(item, index);
@@ -2950,14 +3104,46 @@ class ClaudemanApp {
// Close wizard
this.closeRalphWizard();
// Build the full prompt
// Determine if we have a task plan
const hasPlan = config.generatedPlan && config.generatedPlan.filter(i => i.enabled).length > 0;
// Build the full prompt following Ralph Wiggum methodology
let fullPrompt = config.taskDescription;
fullPrompt += '\n\n---\n\n';
// Add @fix_plan.md reference if plan was generated
if (hasPlan) {
fullPrompt += '## Task Plan\n\n';
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
fullPrompt += '- Reference the plan at the start of each iteration\n';
fullPrompt += '- Update task checkboxes as you complete items\n';
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
}
fullPrompt += '## Iteration Protocol\n\n';
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
fullPrompt += '1. Check what work has already been done\n';
fullPrompt += '2. Make incremental progress toward completion\n';
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
fullPrompt += '## Verification\n\n';
fullPrompt += 'After each significant change:\n';
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
fullPrompt += '- Check for type/lint errors if applicable\n';
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
fullPrompt += '## Completion Criteria\n\n';
fullPrompt += 'When ALL requirements above are complete:\n';
fullPrompt += '1. Run all tests to verify\n';
fullPrompt += '2. Commit your changes\n';
fullPrompt += `3. Output: <promise>${config.completionPhrase}</promise>\n`;
fullPrompt += `Output \`<promise>${config.completionPhrase}</promise>\` when ALL of the following are true:\n`;
fullPrompt += '- All requirements from the task description are implemented\n';
fullPrompt += '- All tests pass\n';
fullPrompt += '- Changes are committed\n\n';
fullPrompt += '## If Stuck\n\n';
fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
fullPrompt += '1. Document what you\'ve tried\n';
fullPrompt += '2. Identify the specific blocker\n';
fullPrompt += '3. Try an alternative approach\n';
fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n';
try {
// Step 1: Create/verify case exists and start session
@@ -2996,12 +3182,20 @@ class ClaudemanApp {
const enabledItems = config.generatedPlan.filter(i => i.enabled);
if (enabledItems.length > 0) {
const planContent = this.generateFixPlanContent(enabledItems);
// Import the plan content
await fetch(`/api/sessions/${sessionId}/fix-plan/import`, {
// Import the plan content and get the todos back
const importRes = await fetch(`/api/sessions/${sessionId}/fix-plan/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: planContent })
});
const importData = await importRes.json();
// Update local ralph state immediately to avoid 100% display bug
// (SSE event might arrive after selectSession is called)
if (importData.success && importData.data?.todos) {
this.updateRalphState(sessionId, { todos: importData.data.todos });
}
// Write to disk
await fetch(`/api/sessions/${sessionId}/fix-plan/write`, { method: 'POST' });
}
@@ -3023,16 +3217,58 @@ class ClaudemanApp {
// Step 4: Switch to session and wait for it to be ready
await this.selectSession(sessionId);
// Wait a moment for session to be ready, then send the prompt
await new Promise(r => setTimeout(r, 2000));
// Wait for Claude CLI to be ready (shows prompt)
// Check session status multiple times - Claude CLI can take a while to initialize
let attempts = 0;
const maxAttempts = 20; // 10 seconds total
let sessionReady = false;
console.log('[RalphWizard] Waiting for session to become ready...');
while (attempts < maxAttempts) {
await new Promise(r => setTimeout(r, 500));
try {
const statusRes = await fetch(`/api/sessions/${sessionId}`);
const statusData = await statusRes.json();
// Session is ready if:
// 1. Status is explicitly 'idle', OR
// 2. isWorking is false (Claude is at prompt), OR
// 3. Terminal buffer contains the prompt character
const isIdle = statusData?.status === 'idle';
const notWorking = statusData?.isWorking === false;
const hasPrompt = statusData?.terminalBuffer?.includes('❯');
if (isIdle || notWorking || hasPrompt) {
console.log(`[RalphWizard] Session ready after ${attempts + 1} attempts (idle=${isIdle}, notWorking=${notWorking}, hasPrompt=${hasPrompt})`);
sessionReady = true;
break;
}
} catch (e) {
console.warn('[RalphWizard] Error checking session status:', e);
}
attempts++;
}
// Send the prompt to start the loop
await fetch(`/api/sessions/${sessionId}/input`, {
if (!sessionReady) {
console.warn(`[RalphWizard] Session did not become ready after ${maxAttempts} attempts, sending prompt anyway`);
this.showToast('Session took longer than expected to initialize, sending prompt...', 'warning');
// Add extra delay to give Claude CLI more time
await new Promise(r => setTimeout(r, 2000));
}
// Send the prompt to start the loop using writeViaScreen (more reliable)
// Use \r for Enter (carriage return), not \n (newline)
console.log('[RalphWizard] Sending prompt to session...');
const inputRes = await fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: fullPrompt + '\n' })
body: JSON.stringify({ input: fullPrompt + '\r', useScreen: true })
});
if (!inputRes.ok) {
const errorData = await inputRes.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to send input: ${inputRes.status}`);
}
console.log('[RalphWizard] Prompt sent successfully');
this.showToast(`Ralph Loop started in ${config.caseName}`, 'success');
} catch (err) {
@@ -5929,16 +6165,20 @@ class ClaudemanApp {
}
updateRalphRing(percent) {
// Ensure percent is a valid number between 0-100
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
// Mini ring (in summary)
const miniProgress = this.$('ralphRingMiniProgress');
const miniText = this.$('ralphRingMiniText');
if (miniProgress) {
// Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100
const offset = 100 - percent;
// offset = 100 means 0% visible, offset = 0 means 100% visible
const offset = 100 - safePercent;
miniProgress.style.strokeDashoffset = offset;
}
if (miniText) {
miniText.textContent = `${percent}%`;
miniText.textContent = `${safePercent}%`;
}
// Large ring (in expanded view)
@@ -5946,11 +6186,12 @@ class ClaudemanApp {
const largePercent = this.$('ralphRingPercent');
if (largeProgress) {
// Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264
const offset = 264 - (264 * percent / 100);
// offset = 264 means 0% visible, offset = 0 means 100% visible
const offset = 264 - (264 * safePercent / 100);
largeProgress.style.strokeDashoffset = offset;
}
if (largePercent) {
largePercent.textContent = `${percent}%`;
largePercent.textContent = `${safePercent}%`;
}
}
+12 -7
View File
@@ -104,7 +104,7 @@
</linearGradient>
</defs>
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
<circle class="ralph-ring-progress-mini" cx="18" cy="18" r="15.9" id="ralphRingMiniProgress" />
<circle class="ralph-ring-progress-mini" cx="18" cy="18" r="15.9" id="ralphRingMiniProgress" style="stroke-dashoffset: 100" />
</svg>
<span class="ralph-ring-text" id="ralphRingMiniText">0%</span>
</div>
@@ -150,7 +150,7 @@
</linearGradient>
</defs>
<circle class="ralph-ring-track" cx="50" cy="50" r="42" />
<circle class="ralph-ring-fill" cx="50" cy="50" r="42" id="ralphRingProgress" />
<circle class="ralph-ring-fill" cx="50" cy="50" r="42" id="ralphRingProgress" style="stroke-dashoffset: 264" />
</svg>
<div class="ralph-ring-center">
<div class="ralph-ring-percent" id="ralphRingPercent">0%</div>
@@ -984,9 +984,9 @@
<div class="wizard-inline-row">
<div class="wizard-inline-field">
<label>Working Directory</label>
<label>Working Directory <span id="existingPlanBadge" class="existing-plan-badge" style="display:none"></span></label>
<div class="case-select-inline">
<select id="ralphCaseSelect" class="form-select"></select>
<select id="ralphCaseSelect" class="form-select" onchange="app.onRalphCaseChange()"></select>
<button class="btn-toolbar btn-sm" onclick="app.showCreateCaseModal()">+</button>
</div>
</div>
@@ -1006,8 +1006,13 @@
<!-- Step 2: Plan Generation -->
<div class="wizard-page wizard-page-compact hidden" id="ralphWizardStep2">
<!-- Loading state (shown immediately) -->
<div id="planGenerationLoading" class="plan-loading-state">
<!-- Existing plan detected (shown when @fix_plan.md exists) -->
<div id="existingPlanSection" class="plan-section hidden">
<!-- Content populated by app.updateExistingPlanUI() -->
</div>
<!-- Loading state (shown during generation) -->
<div id="planGenerationLoading" class="plan-loading-state hidden">
<div class="plan-loading-content">
<div class="plan-spinner"></div>
<div class="plan-loading-text">
@@ -1031,7 +1036,7 @@
<!-- Editor state -->
<div id="planEditor" class="plan-section hidden">
<div class="plan-editor-header">
<span>Implementation Plan</span>
<span>Implementation Plan <span class="plan-stats" id="planStats"></span></span>
<div class="plan-editor-actions">
<div class="plan-option-group">
<label>Detail:</label>
+70 -1
View File
@@ -5433,6 +5433,68 @@ kbd {
justify-content: center;
}
/* Existing Plan Badge (in case selector) */
.existing-plan-badge {
font-size: 0.7rem;
background: var(--accent);
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
margin-left: 0.5rem;
font-weight: normal;
}
/* Existing Plan Card (Step 2) */
.existing-plan-card {
background: var(--bg-input);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 1rem;
text-align: left;
}
.existing-plan-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
margin-bottom: 0.75rem;
}
.existing-plan-icon {
font-size: 1.2rem;
}
.existing-plan-stats {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.existing-plan-stats .stat {
display: flex;
align-items: center;
gap: 0.25rem;
}
.existing-plan-stats .stat.pending {
color: var(--yellow);
}
.existing-plan-stats .stat.completed {
color: var(--green);
}
.existing-plan-stats .stat.total {
color: var(--text-muted);
}
.existing-plan-actions {
display: flex;
gap: 0.5rem;
}
/* Plan Loading State */
.plan-loading-state {
padding: 1.5rem 1rem;
@@ -5563,6 +5625,13 @@ kbd {
font-size: 0.9rem;
}
.plan-stats {
font-weight: normal;
font-size: 0.8rem;
color: var(--text-muted);
margin-left: 0.5rem;
}
.plan-editor-actions {
display: flex;
gap: 0.35rem;
@@ -5572,7 +5641,7 @@ kbd {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 160px;
max-height: 300px;
overflow-y: auto;
margin-bottom: 0.5rem;
}
+158 -14
View File
@@ -417,6 +417,12 @@ export class WebServer extends EventEmitter {
// API Routes
this.app.get('/api/status', async () => this.getFullState());
// Cleanup stale sessions from state file
this.app.post('/api/cleanup-state', async () => {
const cleaned = this.cleanupStaleSessions();
return { success: true, cleanedSessions: cleaned };
});
// Global stats endpoint
this.app.get('/api/stats', async () => {
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
@@ -1271,9 +1277,10 @@ export class WebServer extends EventEmitter {
});
// Send input to interactive session
// useScreen: true uses writeViaScreen which is more reliable for programmatic input
this.app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const { input } = req.body as SessionInputRequest;
const { input, useScreen } = req.body as SessionInputRequest & { useScreen?: boolean };
const session = this.sessions.get(id);
if (!session) {
@@ -1289,8 +1296,21 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
}
session.write(inputStr);
return { success: true };
// Use writeViaScreen for programmatic input (more reliable for screen sessions)
let success = false;
if (useScreen) {
success = session.writeViaScreen(inputStr);
if (!success) {
console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`);
// Fallback to direct write if screen write fails
session.write(inputStr);
success = true; // Direct write doesn't return status, assume success
}
} else {
session.write(inputStr);
success = true;
}
return { success };
});
// Resize session terminal
@@ -1939,6 +1959,112 @@ export class WebServer extends EventEmitter {
};
});
// Read @fix_plan.md from a case directory (for wizard to detect existing plans)
this.app.get('/api/cases/:name/fix-plan', async (req) => {
const { name } = req.params as { name: string };
// Get case path (check linked cases first, then casesDir)
let casePath: string | null = null;
const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json');
try {
if (existsSync(linkedCasesFile)) {
const linkedCases: Record<string, string> = JSON.parse(readFileSync(linkedCasesFile, 'utf-8'));
if (linkedCases[name]) {
casePath = linkedCases[name];
}
}
} catch {
// Ignore errors
}
if (!casePath) {
casePath = join(casesDir, name);
}
const fixPlanPath = join(casePath, '@fix_plan.md');
if (!existsSync(fixPlanPath)) {
return { success: true, exists: false, content: null, todos: [] };
}
try {
const content = readFileSync(fixPlanPath, 'utf-8');
// Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown)
const todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed'; priority: string | null }> = [];
const todoPattern = /^-\s*\[([ xX\-])\]\s*(.+)$/;
const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i;
const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i;
const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i;
const completedHeaderPattern = /^##\s*Completed/i;
let currentPriority: string | null = null;
let inCompletedSection = false;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (p0HeaderPattern.test(trimmed)) {
currentPriority = 'P0';
inCompletedSection = false;
continue;
}
if (p1HeaderPattern.test(trimmed)) {
currentPriority = 'P1';
inCompletedSection = false;
continue;
}
if (p2HeaderPattern.test(trimmed)) {
currentPriority = 'P2';
inCompletedSection = false;
continue;
}
if (completedHeaderPattern.test(trimmed)) {
inCompletedSection = true;
continue;
}
const match = trimmed.match(todoPattern);
if (match) {
const [, checkboxState, taskContent] = match;
let status: 'pending' | 'in_progress' | 'completed';
if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') {
status = 'completed';
} else if (checkboxState === '-') {
status = 'in_progress';
} else {
status = 'pending';
}
todos.push({
content: taskContent.trim(),
status,
priority: inCompletedSection ? null : currentPriority,
});
}
}
const stats = {
total: todos.length,
pending: todos.filter(t => t.status === 'pending').length,
inProgress: todos.filter(t => t.status === 'in_progress').length,
completed: todos.filter(t => t.status === 'completed').length,
};
return {
success: true,
exists: true,
content,
todos,
stats,
};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`);
}
});
// Quick Start: Create case (if needed) and start interactive session in one click
this.app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => {
// Prevent unbounded session creation
@@ -2055,7 +2181,6 @@ export class WebServer extends EventEmitter {
// Generate implementation plan from task description using Claude
interface GeneratePlanRequest {
taskDescription: string;
maxItems?: number;
detailLevel?: 'brief' | 'standard' | 'detailed';
}
@@ -2067,7 +2192,6 @@ export class WebServer extends EventEmitter {
this.app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
const {
taskDescription,
maxItems = 12,
detailLevel = 'standard'
} = req.body as GeneratePlanRequest;
@@ -2081,11 +2205,11 @@ export class WebServer extends EventEmitter {
// Build sophisticated prompt based on Ralph Wiggum methodology
const detailConfig = {
brief: { minSteps: 5, maxSteps: maxItems, testDepth: 'basic' },
standard: { minSteps: 8, maxSteps: maxItems, testDepth: 'thorough' },
detailed: { minSteps: 12, maxSteps: Math.max(maxItems, 15), testDepth: 'comprehensive' },
brief: { style: 'high-level milestones', testDepth: 'basic' },
standard: { style: 'balanced implementation steps', testDepth: 'thorough' },
detailed: { style: 'granular sub-tasks with full TDD coverage', testDepth: 'comprehensive' },
};
const config = detailConfig[detailLevel] || detailConfig.standard;
const levelConfig = detailConfig[detailLevel] || detailConfig.standard;
const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan.
@@ -2095,12 +2219,18 @@ ${taskDescription}
## YOUR MISSION
Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology.
Think deeply about:
- What are all the components needed?
- What could go wrong? Add defensive steps.
- How will we verify each part works?
- What are ALL the components, modules, and features needed?
- What could go wrong? Add defensive steps for error handling.
- How will we verify each part works? Tests before implementation.
- What edge cases need handling?
- What's the logical order of dependencies?
## PLAN STRUCTURE (${config.minSteps}-${config.maxSteps} steps)
## DETAIL LEVEL: ${detailLevel.toUpperCase()}
Style: ${levelConfig.style}
Generate as many steps as needed to properly cover the task - don't artificially limit yourself.
For complex projects, this could be 30, 50, or even 100+ steps. Quality over brevity.
## PLAN STRUCTURE
Your plan MUST include these phases in order:
@@ -2197,7 +2327,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
priority = obj.priority;
}
return { content, priority };
}).slice(0, maxItems);
});
// No artificial limit - let Claude generate what's needed
} catch (parseErr) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan JSON: ' + getErrorMessage(parseErr));
@@ -3594,6 +3725,16 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
}
/**
* Cleans up stale sessions from state file that don't have active sessions.
* Called on startup and can be called via API endpoint.
* @returns Number of sessions cleaned up
*/
private cleanupStaleSessions(): number {
const activeSessionIds = new Set(this.sessions.keys());
return this.store.cleanupStaleSessions(activeSessionIds);
}
private getFullState() {
// Build respawn status map
const respawnStatus: Record<string, ReturnType<RespawnController['getStatus']>> = {};
@@ -3865,6 +4006,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// This prevents race conditions where clients connect before state is ready
await this.restoreScreenSessions();
// Clean up stale sessions from state file that don't have active screens
this.cleanupStaleSessions();
await this.app.listen({ port: this.port, host: '0.0.0.0' });
const protocol = this.https ? 'https' : 'http';
console.log(`Claudeman web interface running at ${protocol}://localhost:${this.port}`);