diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index 91047c6a..d17e1712 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -20,12 +20,63 @@ import { ScreenManager } from './screen-manager.js'; // Types // ============================================================================ +/** Development phase in TDD cycle */ +export type PlanPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review'; + +/** Task execution status */ +export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'; + +/** + * Enhanced plan item with verification, dependencies, and execution tracking. + * Supports TDD workflow, failure tracking, and plan versioning. + */ export interface PlanItem { + /** Unique identifier (e.g., "P0-001") */ + id?: string; + /** Task description */ content: string; + /** Criticality level */ priority: 'P0' | 'P1' | 'P2' | null; + /** Which subagent generated this item */ source?: string; + /** Why this task is needed */ rationale?: string; + /** Legacy numeric phase (1-4) */ phase?: number; + + // === NEW: Verification === + /** How to know it's done (e.g., "npm test passes", "endpoint returns 200") */ + verificationCriteria?: string; + /** Command to run for verification (e.g., "npm test -- --grep='auth'") */ + testCommand?: string; + + // === NEW: Dependencies === + /** IDs of tasks that must complete first */ + dependencies?: string[]; + + // === NEW: Execution tracking === + /** Current execution status */ + status?: PlanTaskStatus; + /** How many times attempted */ + attempts?: number; + /** Most recent failure reason */ + lastError?: string; + /** Timestamp of completion */ + completedAt?: number; + + // === NEW: Metadata === + /** Estimated complexity */ + complexity?: 'low' | 'medium' | 'high'; + /** How to undo if needed */ + rollbackStrategy?: string; + /** Plan version this belongs to */ + version?: number; + /** TDD phase category */ + tddPhase?: PlanPhase; + /** ID of paired test/impl task */ + pairedWith?: string; + /** Checklist items for review tasks (tddPhase: 'review') */ + reviewChecklist?: string[]; } export interface SubagentResult { @@ -131,7 +182,7 @@ Return ONLY a JSON array: Generate 10-20 items. Think about the complete system architecture.`; -const TESTING_SPECIALIST_PROMPT = `You are a TDD Specialist designing a comprehensive test strategy. +const TESTING_SPECIALIST_PROMPT = `You are a TDD Specialist designing a comprehensive test strategy with verification criteria. ## YOUR TASK Design test coverage for this task: @@ -145,14 +196,26 @@ Following Test-Driven Development methodology: 2. Plan integration tests for feature interactions 3. Identify edge cases and boundary conditions 4. Consider error scenarios and failure modes -5. Plan verification steps +5. For each test, specify HOW to verify it passes ## OUTPUT FORMAT Return ONLY a JSON array: [ - {"category": "unit|integration|edge-case|error|verification", "content": "test description", "rationale": "what it validates"} + { + "category": "unit|integration|edge-case|error|verification", + "content": "Write test for user login with valid credentials", + "rationale": "Validates happy path authentication flow", + "verificationCriteria": "Test passes: POST /auth/login returns 200 with JWT token", + "testCommand": "npm test -- --grep='login valid'", + "pairedImpl": "Implement login endpoint handler" + } ] +CRITICAL: Every test item MUST include: +- verificationCriteria: How to know the test passes (observable outcome) +- testCommand: The actual command to run (npm test, pytest, etc.) +- pairedImpl: The implementation step this test validates + Generate 12-25 items. Tests should be written BEFORE implementation.`; const RISK_ANALYST_PROMPT = `You are a Risk Analyst identifying potential issues and blockers. @@ -178,6 +241,45 @@ Return ONLY a JSON array: Generate 8-15 items. Being proactive about risks prevents surprises.`; +// @ts-expect-error Reserved for future use - code review specialist prompt +const CODE_REVIEWER_PROMPT = `You are a Code Review Specialist designing post-implementation review tasks. + +## YOUR TASK +Design code review steps for implementations in this task: + +## TASK DESCRIPTION +{TASK} + +## INSTRUCTIONS +For each implementation identified, create a review task that checks: +1. **Best Practices**: Language-specific conventions and idioms +2. **Security**: OWASP top 10, input validation, authentication +3. **Performance**: Time complexity, memory usage, N+1 queries +4. **Error Handling**: Edge cases covered, meaningful error messages +5. **Code Quality**: DRY, SOLID principles, readability +6. **Type Safety**: Proper typing, no implicit any, null checks + +## REVIEW TASK GUIDELINES +- Review tasks run AFTER implementation, BEFORE merge +- Each review should be specific and actionable +- Include what to look for and how to verify +- Reference language-specific linting tools where applicable + +## OUTPUT FORMAT +Return ONLY a JSON array: +[ + { + "category": "security|performance|quality|error-handling|best-practices|type-safety", + "content": "Review authentication handler for XSS vulnerabilities", + "rationale": "User input flows through auth - must sanitize", + "verificationCriteria": "No unescaped user input, all inputs validated", + "reviewChecklist": ["Check input sanitization", "Verify CSRF tokens", "Review session handling"], + "implToReview": "Implement authentication handler" + } +] + +Generate 5-10 review tasks. Code review catches bugs that tests miss.`; + const VERIFICATION_PROMPT = `You are a Plan Verification Expert reviewing an implementation plan for completeness and quality. ## ORIGINAL TASK @@ -187,30 +289,84 @@ const VERIFICATION_PROMPT = `You are a Plan Verification Expert reviewing an imp {PLAN} ## YOUR MISSION -Review this plan and: +Review and enhance this plan: 1. Assign priorities (P0=critical/blocking, P1=required, P2=enhancement) -2. Identify any gaps or missing steps -3. Check logical ordering (tests before implementation, setup before coding) -4. Flag potential issues or warnings -5. Calculate an overall quality score (0.0-1.0) +2. Add verification criteria to EVERY task (how to know it's done) +3. Pair test tasks with implementation tasks (TDD cycle) +4. Add dependencies where one task blocks another +5. Identify gaps and calculate quality score ## PRIORITY GUIDELINES - P0: Foundation tasks, type definitions, project setup, blocking dependencies - P1: Core implementation, tests, main features, error handling - P2: Polish, optimization, documentation, nice-to-have features +## TDD + REVIEW CYCLE RULES +The complete cycle is: test → impl → review +- Every implementation task should have a corresponding test task AND review task +- Test task comes BEFORE its paired implementation task +- Review task comes AFTER the implementation it reviews +- Use "pairedWith" to link test ↔ implementation ↔ review +- Verification criteria should reference test results where applicable + +## REVIEW TASK REQUIREMENTS +After EVERY implementation task, add a review task that checks: +- Best practices for the language/framework +- Security vulnerabilities (OWASP top 10) +- Performance concerns +- Error handling completeness +- Code quality (DRY, SOLID, readability) + ## OUTPUT FORMAT Return ONLY a JSON object: { "validatedPlan": [ - {"content": "step description", "priority": "P0|P1|P2", "rationale": "why this priority"} + { + "id": "P0-001", + "content": "Write failing test for user authentication", + "priority": "P0", + "tddPhase": "test", + "verificationCriteria": "Test file exists, test fails with 'not implemented'", + "testCommand": "npm test -- --grep='auth'", + "pairedWith": "P0-002", + "dependencies": [], + "complexity": "low" + }, + { + "id": "P0-002", + "content": "Implement user authentication handler", + "priority": "P0", + "tddPhase": "impl", + "verificationCriteria": "npm test -- --grep='auth' passes", + "pairedWith": "P0-001", + "dependencies": ["P0-001"], + "complexity": "medium" + }, + { + "id": "P0-003", + "content": "Review auth implementation for security and best practices", + "priority": "P0", + "tddPhase": "review", + "verificationCriteria": "No security issues found, follows TypeScript best practices", + "reviewChecklist": ["Input validation", "XSS prevention", "Session security", "Error handling"], + "pairedWith": "P0-002", + "dependencies": ["P0-002"], + "complexity": "low" + } ], "gaps": ["missing requirement 1", "missing test coverage for X"], "warnings": ["consider Y before Z", "potential issue with..."], "qualityScore": 0.85 } -Be critical but constructive. A thorough review catches issues early.`; +CRITICAL REQUIREMENTS: +1. EVERY task MUST have verificationCriteria (how to verify completion) +2. Implementation tasks MUST have a paired test task AND a review task +3. Review tasks MUST have a reviewChecklist with specific items to check +4. Dependencies must form a valid DAG (no cycles) +5. Use sequential IDs: P0-001, P0-002, P0-003, P1-001, etc. + +Be critical but constructive. A thorough review catches issues that tests miss.`; // ============================================================================ // Main Orchestrator Class @@ -270,11 +426,19 @@ export class PlanOrchestrator { totalCost += 0.01; // Verification cost estimate + // Phase 4: Ensure all impl tasks have review tasks + onProgress?.('review-injection', 'Ensuring review tasks for all implementations...'); + const planWithReviews = this.ensureReviewTasks(verificationResult.validatedPlan); + const reviewsAdded = planWithReviews.length - verificationResult.validatedPlan.length; + if (reviewsAdded > 0) { + onProgress?.('review-injection', `Added ${reviewsAdded} auto-review task(s)`); + } + const totalDurationMs = Date.now() - startTime; return { success: true, - items: verificationResult.validatedPlan, + items: planWithReviews, costUsd: totalCost, metadata: { subagentResults, @@ -573,19 +737,51 @@ export class PlanOrchestrator { const parsed = JSON.parse(jsonMatch[0]); - const validatedPlan: PlanItem[] = (parsed.validatedPlan || []).map((item: unknown) => { + const validatedPlan: PlanItem[] = (parsed.validatedPlan || []).map((item: unknown, idx: number) => { if (typeof item !== 'object' || item === null) { - return { content: String(item), priority: 'P1' as const }; + return { content: String(item), priority: 'P1' as const, id: `task-${idx}` }; } const obj = item as Record; let priority: PlanItem['priority'] = null; if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') { priority = obj.priority; } + + // Parse TDD phase (now includes 'review') + let tddPhase: PlanItem['tddPhase']; + if (obj.tddPhase === 'setup' || obj.tddPhase === 'test' || obj.tddPhase === 'impl' || obj.tddPhase === 'verify' || obj.tddPhase === 'review') { + tddPhase = obj.tddPhase; + } + + // Parse complexity + let complexity: PlanItem['complexity']; + if (obj.complexity === 'low' || obj.complexity === 'medium' || obj.complexity === 'high') { + complexity = obj.complexity; + } + + // Parse reviewChecklist for review tasks + let reviewChecklist: string[] | undefined; + if (Array.isArray(obj.reviewChecklist)) { + reviewChecklist = obj.reviewChecklist.map(String); + } + return { + id: obj.id ? String(obj.id) : `task-${idx}`, content: String(obj.content || ''), priority, rationale: obj.rationale ? String(obj.rationale) : undefined, + // Enhanced fields + verificationCriteria: obj.verificationCriteria ? String(obj.verificationCriteria) : undefined, + testCommand: obj.testCommand ? String(obj.testCommand) : undefined, + tddPhase, + pairedWith: obj.pairedWith ? String(obj.pairedWith) : undefined, + dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : undefined, + complexity, + reviewChecklist, + // Execution tracking defaults + status: 'pending' as PlanTaskStatus, + attempts: 0, + version: 1, }; }); @@ -611,13 +807,21 @@ export class PlanOrchestrator { /** * Fallback verification when the verification subagent fails. + * Assigns heuristic priorities and adds basic verification criteria. */ private fallbackVerification(items: PlanItem[]): VerificationResult { return { - validatedPlan: items.map(item => ({ + validatedPlan: items.map((item, idx) => ({ ...item, + id: item.id || `task-${idx}`, priority: item.phase === 1 ? 'P0' as const : item.phase === 4 ? 'P2' as const : 'P1' as const, + // Add default verification criteria based on content + verificationCriteria: item.verificationCriteria || + this.inferVerificationCriteria(item.content), + status: 'pending' as PlanTaskStatus, + attempts: 0, + version: 1, })), gaps: [], warnings: ['Verification subagent failed - using heuristic priorities'], @@ -625,6 +829,34 @@ export class PlanOrchestrator { }; } + /** + * Infer verification criteria from task content. + */ + private inferVerificationCriteria(content: string): string { + const lower = content.toLowerCase(); + + if (lower.includes('test')) { + return 'Tests pass without errors'; + } + if (lower.includes('implement') || lower.includes('create') || lower.includes('add')) { + return 'Code compiles, no type errors'; + } + if (lower.includes('fix') || lower.includes('debug')) { + return 'Issue is resolved, tests pass'; + } + if (lower.includes('refactor')) { + return 'Code refactored, all tests still pass'; + } + if (lower.includes('document') || lower.includes('readme')) { + return 'Documentation exists and is accurate'; + } + if (lower.includes('config') || lower.includes('setup')) { + return 'Configuration is valid, app starts'; + } + + return 'Task completed successfully'; + } + /** * Create a timeout promise. */ @@ -633,4 +865,130 @@ export class PlanOrchestrator { setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms); }); } + + /** + * Inject review tasks for any implementation tasks that don't have them. + * Called as a post-processing step to ensure the test → impl → review cycle is complete. + * + * @param items - The validated plan items + * @returns Updated plan items with review tasks added + */ + ensureReviewTasks(items: PlanItem[]): PlanItem[] { + const result: PlanItem[] = []; + const implTasksNeedingReview: Map = new Map(); + + // First pass: identify impl tasks and their existing review pairs + const reviewPairs = new Set(); + for (const item of items) { + if (item.tddPhase === 'review' && item.pairedWith) { + reviewPairs.add(item.pairedWith); + } + } + + // Second pass: collect impl tasks without review pairs + for (const item of items) { + if (item.tddPhase === 'impl' && item.id && !reviewPairs.has(item.id)) { + implTasksNeedingReview.set(item.id, item); + } + } + + // Third pass: build result with injected review tasks + for (const item of items) { + result.push(item); + + // If this is an impl task needing review, inject one after it + if (item.id && implTasksNeedingReview.has(item.id)) { + const reviewId = this.generateReviewId(item.id); + const reviewTask = this.createReviewTask(item, reviewId); + result.push(reviewTask); + } + } + + return result; + } + + /** + * Generate a review task ID from an impl task ID. + * P0-002 → P0-002-R, task-5 → task-5-R + */ + private generateReviewId(implId: string): string { + return `${implId}-R`; + } + + /** + * Create a review task for an implementation task. + */ + private createReviewTask(implTask: PlanItem, reviewId: string): PlanItem { + const reviewChecklist = this.generateReviewChecklist(implTask.content); + + return { + id: reviewId, + content: `Review: ${implTask.content}`, + priority: implTask.priority, + tddPhase: 'review', + pairedWith: implTask.id, + dependencies: implTask.id ? [implTask.id] : [], + verificationCriteria: 'Code review complete, no issues found or all issues addressed', + reviewChecklist, + status: 'pending', + attempts: 0, + version: implTask.version || 1, + complexity: 'low', + }; + } + + /** + * Generate a review checklist based on the implementation task content. + */ + private generateReviewChecklist(content: string): string[] { + const lower = content.toLowerCase(); + const checklist: string[] = []; + + // Always include these + checklist.push('Code compiles without errors'); + checklist.push('No TypeScript/linting warnings'); + + // Security checks for certain patterns + if (lower.includes('auth') || lower.includes('login') || lower.includes('password')) { + checklist.push('Input validation implemented'); + checklist.push('No sensitive data in logs'); + checklist.push('Secure session handling'); + } + + if (lower.includes('api') || lower.includes('endpoint') || lower.includes('route')) { + checklist.push('Request validation'); + checklist.push('Error responses do not leak internals'); + checklist.push('Rate limiting considered'); + } + + if (lower.includes('database') || lower.includes('query') || lower.includes('sql')) { + checklist.push('Parameterized queries used'); + checklist.push('No N+1 query issues'); + } + + if (lower.includes('file') || lower.includes('path') || lower.includes('upload')) { + checklist.push('Path traversal prevented'); + checklist.push('File type validation'); + } + + if (lower.includes('user') || lower.includes('input')) { + checklist.push('XSS prevention'); + checklist.push('Input sanitization'); + } + + // Performance checks + if (lower.includes('loop') || lower.includes('iterate') || lower.includes('array')) { + checklist.push('Algorithm complexity is appropriate'); + } + + // Error handling + checklist.push('Error cases handled'); + checklist.push('Meaningful error messages'); + + // Code quality + checklist.push('Code is readable and maintainable'); + checklist.push('No duplicate logic'); + + return checklist; + } } diff --git a/src/screen-manager.ts b/src/screen-manager.ts index f4fc5dd6..e0c1c862 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -365,6 +365,38 @@ export class ScreenManager extends EventEmitter { return pids; } + // Check if a process is still alive + private isProcessAlive(pid: number): boolean { + try { + // signal 0 doesn't kill, just checks if process exists + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + // Verify all PIDs are dead, with retry + private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise { + const startTime = Date.now(); + const checkInterval = 50; + + while (Date.now() - startTime < maxWaitMs) { + const aliveCount = pids.filter(pid => this.isProcessAlive(pid)).length; + if (aliveCount === 0) { + return true; + } + await new Promise(resolve => setTimeout(resolve, checkInterval)); + } + + // Log any processes that are still alive + const stillAlive = pids.filter(pid => this.isProcessAlive(pid)); + if (stillAlive.length > 0) { + console.warn(`[ScreenManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`); + } + return stillAlive.length === 0; + } + // Kill a screen session and all its child processes async killScreen(sessionId: string): Promise { const screen = this.screens.get(sessionId); @@ -377,40 +409,54 @@ export class ScreenManager extends EventEmitter { console.log(`[ScreenManager] Killing screen ${screen.screenName} (PID ${currentPid})`); + // Collect all PIDs to track (for verification) + const allPids: number[] = [currentPid]; + // Strategy 1: Find and kill all child processes recursively - const childPids = this.getChildPids(currentPid); + // Re-check for children before each kill attempt (they may have changed) + let childPids = this.getChildPids(currentPid); if (childPids.length > 0) { console.log(`[ScreenManager] Found ${childPids.length} child processes to kill`); + allPids.push(...childPids); // Kill children in reverse order (deepest first) with SIGTERM - for (const childPid of childPids.reverse()) { - try { - process.kill(childPid, 'SIGTERM'); - } catch { - // Process may already be dead + for (const childPid of [...childPids].reverse()) { + if (this.isProcessAlive(childPid)) { + try { + process.kill(childPid, 'SIGTERM'); + } catch { + // Process may already be dead + } } } // Give processes a moment to terminate gracefully await new Promise(resolve => setTimeout(resolve, SCREEN_KILL_WAIT_MS)); - // Force kill any remaining children + // Re-check which children are still alive and force kill them + childPids = this.getChildPids(currentPid); for (const childPid of childPids) { - try { - process.kill(childPid, 'SIGKILL'); - } catch { - // Process already terminated + if (this.isProcessAlive(childPid)) { + try { + process.kill(childPid, 'SIGKILL'); + } catch { + // Process already terminated + } } } } // Strategy 2: Kill the entire process group (catches any orphans we missed) - try { - process.kill(-currentPid, 'SIGTERM'); - await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS)); - process.kill(-currentPid, 'SIGKILL'); - } catch { - // Process group may not exist or already terminated + if (this.isProcessAlive(currentPid)) { + try { + process.kill(-currentPid, 'SIGTERM'); + await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS)); + if (this.isProcessAlive(currentPid)) { + process.kill(-currentPid, 'SIGKILL'); + } + } catch { + // Process group may not exist or already terminated + } } // Strategy 3: Kill screen session by name @@ -423,10 +469,18 @@ export class ScreenManager extends EventEmitter { } // Strategy 4: Direct kill by PID as final fallback - try { - process.kill(currentPid, 'SIGKILL'); - } catch { - // Already dead + if (this.isProcessAlive(currentPid)) { + try { + process.kill(currentPid, 'SIGKILL'); + } catch { + // Already dead + } + } + + // Verify all processes are dead (with timeout) + const allDead = await this.verifyProcessesDead(allPids, 2000); + if (!allDead) { + console.error(`[ScreenManager] Warning: Some processes may still be alive for screen ${screen.screenName}`); } this.screens.delete(sessionId); diff --git a/src/session-manager.ts b/src/session-manager.ts index 1a44953f..c7a68bbf 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -61,6 +61,9 @@ export class SessionManager extends EventEmitter { private sessionHandlers: Map = new Map(); private store = getStore(); + // Mutex for session creation to prevent race conditions + private _sessionCreationLock: Promise | null = null; + /** * Creates a new SessionManager and loads previous session state. */ @@ -84,54 +87,73 @@ export class SessionManager extends EventEmitter { /** * Creates and starts a new Claude session. + * Uses mutex to prevent race conditions when multiple requests arrive simultaneously. * * @param workingDir - Working directory for the session * @returns The newly created session * @throws Error if max concurrent sessions limit reached */ async createSession(workingDir: string): Promise { - const config = this.store.getConfig(); - - if (this.sessions.size >= config.maxConcurrentSessions) { - throw new Error(`Maximum concurrent sessions (${config.maxConcurrentSessions}) reached`); + // Wait for any pending session creation to complete (mutex pattern) + while (this._sessionCreationLock) { + await this._sessionCreationLock; } - const session = new Session({ workingDir }); + // Create a new lock promise that others will wait on + let unlock: () => void; + this._sessionCreationLock = new Promise(resolve => { + unlock = resolve; + }); - // Set up event forwarding with stored handlers for cleanup - const handlers: SessionHandlers = { - output: (data: string) => { - this.emit('sessionOutput', session.id, data); - this.updateSessionState(session); - }, - error: (data: string) => { - this.emit('sessionError', session.id, data); - this.updateSessionState(session); - }, - completion: (phrase: string) => { - this.emit('sessionCompletion', session.id, phrase); - }, - exit: () => { - this.emit('sessionStopped', session.id); - this.updateSessionState(session); - }, - }; + try { + const config = this.store.getConfig(); - session.on('output', handlers.output); - session.on('error', handlers.error); - session.on('completion', handlers.completion); - session.on('exit', handlers.exit); + // Check limit INSIDE the lock to prevent race conditions + if (this.sessions.size >= config.maxConcurrentSessions) { + throw new Error(`Maximum concurrent sessions (${config.maxConcurrentSessions}) reached`); + } - // Store handlers for later cleanup - this.sessionHandlers.set(session.id, handlers); + const session = new Session({ workingDir }); - await session.start(); + // Set up event forwarding with stored handlers for cleanup + const handlers: SessionHandlers = { + output: (data: string) => { + this.emit('sessionOutput', session.id, data); + this.updateSessionState(session); + }, + error: (data: string) => { + this.emit('sessionError', session.id, data); + this.updateSessionState(session); + }, + completion: (phrase: string) => { + this.emit('sessionCompletion', session.id, phrase); + }, + exit: () => { + this.emit('sessionStopped', session.id); + this.updateSessionState(session); + }, + }; - this.sessions.set(session.id, session); - this.store.setSession(session.id, session.toState()); + session.on('output', handlers.output); + session.on('error', handlers.error); + session.on('completion', handlers.completion); + session.on('exit', handlers.exit); - this.emit('sessionStarted', session); - return session; + // Store handlers for later cleanup + this.sessionHandlers.set(session.id, handlers); + + await session.start(); + + this.sessions.set(session.id, session); + this.store.setSession(session.id, session.toState()); + + this.emit('sessionStarted', session); + return session; + } finally { + // Release the lock so other createSession calls can proceed + this._sessionCreationLock = null; + unlock!(); + } } /** diff --git a/src/session.ts b/src/session.ts index 9c50a867..6a7c38b2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -888,15 +888,21 @@ export class Session extends EventEmitter { } // Attach to the screen session via PTY - this.ptyProcess = pty.spawn('screen', [ - '-x', this._screenSession!.screenName - ], { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - env: { ...process.env, TERM: 'xterm-256color' }, - }); + try { + this.ptyProcess = pty.spawn('screen', [ + '-x', this._screenSession!.screenName + ], { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + } catch (spawnErr) { + console.error('[Session] Failed to spawn PTY for screen attachment:', spawnErr); + this.emit('error', `Failed to attach to screen: ${spawnErr}`); + throw spawnErr; + } // For NEW screens: wait for prompt to appear then clean buffer // For RESTORED screens: don't do anything - client will fetch buffer on tab switch @@ -941,23 +947,30 @@ export class Session extends EventEmitter { // Fallback to direct PTY if screen is not used if (!this.ptyProcess) { - this.ptyProcess = pty.spawn('claude', [ - '--dangerously-skip-permissions' - ], { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - env: { - ...process.env, - PATH: getAugmentedPath(), - TERM: 'xterm-256color', - // Inform Claude it's running within Claudeman (helps prevent self-termination) - CLAUDEMAN_SCREEN: '1', - CLAUDEMAN_SESSION_ID: this.id, - CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000', - }, - }); + try { + this.ptyProcess = pty.spawn('claude', [ + '--dangerously-skip-permissions' + ], { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + env: { + ...process.env, + PATH: getAugmentedPath(), + TERM: 'xterm-256color', + // Inform Claude it's running within Claudeman (helps prevent self-termination) + CLAUDEMAN_SCREEN: '1', + CLAUDEMAN_SESSION_ID: this.id, + CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000', + }, + }); + } catch (spawnErr) { + console.error('[Session] Failed to spawn Claude PTY:', spawnErr); + this._status = 'stopped'; + this.emit('error', `Failed to start Claude: ${spawnErr}`); + throw new Error(`Failed to spawn Claude process: ${spawnErr}`); + } } this._pid = this.ptyProcess.pid; @@ -1101,15 +1114,21 @@ export class Session extends EventEmitter { } // Attach to the screen session via PTY - this.ptyProcess = pty.spawn('screen', [ - '-x', this._screenSession!.screenName - ], { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - env: { ...process.env, TERM: 'xterm-256color' }, - }); + try { + this.ptyProcess = pty.spawn('screen', [ + '-x', this._screenSession!.screenName + ], { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + } catch (spawnErr) { + console.error('[Session] Failed to spawn PTY for shell screen attachment:', spawnErr); + this.emit('error', `Failed to attach to screen: ${spawnErr}`); + throw spawnErr; + } // For NEW screens: clear by sending 'clear' command to the shell // For RESTORED screens: don't clear - we want to see the existing output @@ -1130,19 +1149,26 @@ export class Session extends EventEmitter { // Fallback to direct PTY if screen is not used if (!this.ptyProcess) { - this.ptyProcess = pty.spawn(shell, [], { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - env: { - ...process.env, - TERM: 'xterm-256color', - CLAUDEMAN_SCREEN: '1', - CLAUDEMAN_SESSION_ID: this.id, - CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000', - }, - }); + try { + this.ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + env: { + ...process.env, + TERM: 'xterm-256color', + CLAUDEMAN_SCREEN: '1', + CLAUDEMAN_SESSION_ID: this.id, + CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000', + }, + }); + } catch (spawnErr) { + console.error('[Session] Failed to spawn shell PTY:', spawnErr); + this._status = 'stopped'; + this.emit('error', `Failed to start shell: ${spawnErr}`); + throw new Error(`Failed to spawn shell process: ${spawnErr}`); + } } this._pid = this.ptyProcess.pid; @@ -1251,21 +1277,27 @@ export class Session extends EventEmitter { } args.push(prompt); - this.ptyProcess = pty.spawn('claude', args, { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - env: { - ...process.env, - PATH: getAugmentedPath(), - TERM: 'xterm-256color', - // Inform Claude it's running within Claudeman - CLAUDEMAN_SCREEN: '1', - CLAUDEMAN_SESSION_ID: this.id, - CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000', - }, - }); + try { + this.ptyProcess = pty.spawn('claude', args, { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + env: { + ...process.env, + PATH: getAugmentedPath(), + TERM: 'xterm-256color', + // Inform Claude it's running within Claudeman + CLAUDEMAN_SCREEN: '1', + CLAUDEMAN_SESSION_ID: this.id, + CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000', + }, + }); + } catch (spawnErr) { + console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr); + this.emit('error', `Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`); + throw spawnErr; + } this._pid = this.ptyProcess.pid; console.log('[Session] PTY spawned with PID:', this._pid); diff --git a/src/state-store.ts b/src/state-store.ts index d899b760..f4f35209 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -43,6 +43,9 @@ const SAVE_DEBOUNCE_MS = 500; * store.saveNow(); * ``` */ +/** Maximum consecutive save failures before circuit breaker opens */ +const MAX_CONSECUTIVE_FAILURES = 3; + export class StateStore { private state: AppState; private filePath: string; @@ -55,6 +58,10 @@ export class StateStore { private ralphStateSaveTimeout: NodeJS.Timeout | null = null; private ralphStateDirty: boolean = false; + // Circuit breaker for save failures (prevents hammering disk on persistent errors) + private consecutiveSaveFailures: number = 0; + private circuitBreakerOpen: boolean = false; + constructor(filePath?: string) { this.filePath = filePath || join(homedir(), '.claudeman', 'state.json'); this.ralphStatePath = this.filePath.replace('.json', '-inner.json'); @@ -109,6 +116,7 @@ export class StateStore { /** * Immediately writes state to disk using atomic write pattern. * Writes to temp file first, then renames to prevent corruption on crash. + * Includes backup mechanism and circuit breaker for reliability. * Use when guaranteed persistence is required (e.g., before shutdown). */ saveNow(): void { @@ -119,32 +127,122 @@ export class StateStore { if (!this.dirty) { return; } + + // Circuit breaker: stop attempting writes after too many failures + if (this.circuitBreakerOpen) { + console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); + return; + } + this.dirty = false; this.ensureDir(); - // Atomic write: write to temp file, then rename (atomic on POSIX) + const tempPath = this.filePath + '.tmp'; + const backupPath = this.filePath + '.bak'; let json: string; + + // Step 1: Serialize state (validates it's JSON-safe) try { json = JSON.stringify(this.state, null, 2); } catch (err) { console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err); - throw err; + this.consecutiveSaveFailures++; + if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); + this.circuitBreakerOpen = true; + } + // Don't throw - this prevents crashing the app + // Mark dirty again so we can retry later + this.dirty = true; + return; } + + // Step 2: Create backup of current state file (if exists) + try { + if (existsSync(this.filePath)) { + // Read current file and verify it's valid JSON before backing up + const currentContent = readFileSync(this.filePath, 'utf-8'); + JSON.parse(currentContent); // Validate + writeFileSync(backupPath, currentContent, 'utf-8'); + } + } catch { + // Backup failed - current file may be corrupt, continue with write + console.warn('[StateStore] Could not create backup (current file may be corrupt)'); + } + + // Step 3: Atomic write: write to temp file, then rename try { writeFileSync(tempPath, json, 'utf-8'); renameSync(tempPath, this.filePath); + + // Success! Reset failure counter + this.consecutiveSaveFailures = 0; + if (this.circuitBreakerOpen) { + console.log('[StateStore] Circuit breaker CLOSED - save succeeded'); + this.circuitBreakerOpen = false; + } } catch (err) { console.error('[StateStore] Failed to write state file:', err); + this.consecutiveSaveFailures++; + // Try to clean up temp file on error try { if (existsSync(tempPath)) { unlinkSync(tempPath); } } catch { /* ignore cleanup errors */ } - throw err; + + // Check circuit breaker threshold + if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); + this.circuitBreakerOpen = true; + } + + // Mark dirty so we retry later (don't throw to avoid crashing app) + this.dirty = true; } } + /** + * Attempt to recover state from backup file. + * Call this if main state file is corrupt. + */ + recoverFromBackup(): boolean { + const backupPath = this.filePath + '.bak'; + try { + if (existsSync(backupPath)) { + const backupContent = readFileSync(backupPath, 'utf-8'); + const parsed = JSON.parse(backupContent) as Partial; + const initial = createInitialState(); + this.state = { + ...initial, + ...parsed, + sessions: { ...parsed.sessions }, + tasks: { ...parsed.tasks }, + ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop }, + config: { ...initial.config, ...parsed.config }, + }; + console.log('[StateStore] Successfully recovered state from backup'); + // Reset circuit breaker after successful recovery + this.circuitBreakerOpen = false; + this.consecutiveSaveFailures = 0; + return true; + } + } catch (err) { + console.error('[StateStore] Failed to recover from backup:', err); + } + return false; + } + + /** + * Reset the circuit breaker (for manual intervention). + */ + resetCircuitBreaker(): void { + this.circuitBreakerOpen = false; + this.consecutiveSaveFailures = 0; + console.log('[StateStore] Circuit breaker manually reset'); + } + /** Flushes any pending main state save. Call before shutdown. */ flush(): void { this.saveNow(); diff --git a/src/web/public/app.js b/src/web/public/app.js index 7982acb7..00047e48 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -496,6 +496,9 @@ class ClaudemanApp { // SSE reconnect timeout (to prevent orphaned timeouts) this.sseReconnectTimeout = null; + // SSE event listener cleanup function (to prevent listener accumulation on reconnect) + this._sseListenerCleanup = null; + // Notification system this.notificationManager = new NotificationManager(this); this.idleTimers = new Map(); // Map for stuck detection @@ -1038,6 +1041,12 @@ class ClaudemanApp { this.sseReconnectTimeout = null; } + // Clean up existing SSE listeners before creating new connection (prevents listener accumulation) + if (this._sseListenerCleanup) { + this._sseListenerCleanup(); + this._sseListenerCleanup = null; + } + // Close existing EventSource before creating new one to prevent duplicate connections if (this.eventSource) { this.eventSource.close(); @@ -1046,6 +1055,23 @@ class ClaudemanApp { this.eventSource = new EventSource('/api/events'); + // Store all event listeners for cleanup on reconnect + const listeners = []; + const addListener = (event, handler) => { + this.eventSource.addEventListener(event, handler); + listeners.push({ event, handler }); + }; + + // Create cleanup function to remove all listeners + this._sseListenerCleanup = () => { + for (const { event, handler } of listeners) { + if (this.eventSource) { + this.eventSource.removeEventListener(event, handler); + } + } + listeners.length = 0; + }; + this.eventSource.onopen = () => this.setConnectionStatus('connected'); this.eventSource.onerror = () => { this.setConnectionStatus('disconnected'); @@ -1061,7 +1087,7 @@ class ClaudemanApp { this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), 3000); }; - this.eventSource.addEventListener('init', (e) => { + addListener('init', (e) => { try { this.handleInit(JSON.parse(e.data)); } catch (err) { @@ -1069,14 +1095,14 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('session:created', (e) => { + addListener('session:created', (e) => { const data = JSON.parse(e.data); this.sessions.set(data.id, data); this.renderSessionTabs(); this.updateCost(); }); - this.eventSource.addEventListener('session:updated', (e) => { + addListener('session:updated', (e) => { const data = JSON.parse(e.data); const session = data.session || data; this.sessions.set(session.id, session); @@ -1091,7 +1117,7 @@ class ClaudemanApp { this.updateSubagentParentNames(session.id); }); - this.eventSource.addEventListener('session:deleted', (e) => { + addListener('session:deleted', (e) => { const data = JSON.parse(e.data); this.sessions.delete(data.id); this.terminalBuffers.delete(data.id); @@ -1120,14 +1146,14 @@ class ClaudemanApp { this.renderProjectInsightsPanel(); // Update project insights panel after session deleted }); - this.eventSource.addEventListener('session:terminal', (e) => { + addListener('session:terminal', (e) => { const data = JSON.parse(e.data); if (data.id === this.activeSessionId) { this.batchTerminalWrite(data.data); } }); - this.eventSource.addEventListener('session:clearTerminal', async (e) => { + addListener('session:clearTerminal', async (e) => { const data = JSON.parse(e.data); if (data.id === this.activeSessionId) { // Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed) @@ -1162,7 +1188,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('session:completion', (e) => { + addListener('session:completion', (e) => { const data = JSON.parse(e.data); this.totalCost += data.cost || 0; this.updateCost(); @@ -1172,7 +1198,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('session:error', (e) => { + addListener('session:error', (e) => { const data = JSON.parse(e.data); if (data.id === this.activeSessionId) { this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`); @@ -1188,7 +1214,7 @@ class ClaudemanApp { }); }); - this.eventSource.addEventListener('session:exit', (e) => { + addListener('session:exit', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.id); if (session) { @@ -1208,7 +1234,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('session:idle', (e) => { + addListener('session:idle', (e) => { const data = JSON.parse(e.data); console.log('[DEBUG] session:idle event for:', data.id); const session = this.sessions.get(data.id); @@ -1236,7 +1262,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('session:working', (e) => { + addListener('session:working', (e) => { const data = JSON.parse(e.data); console.log('[DEBUG] session:working event for:', data.id); const session = this.sessions.get(data.id); @@ -1258,29 +1284,29 @@ class ClaudemanApp { }); // Scheduled run events - this.eventSource.addEventListener('scheduled:created', (e) => { + addListener('scheduled:created', (e) => { this.currentRun = JSON.parse(e.data); this.showTimer(); }); - this.eventSource.addEventListener('scheduled:updated', (e) => { + addListener('scheduled:updated', (e) => { this.currentRun = JSON.parse(e.data); this.updateTimer(); }); - this.eventSource.addEventListener('scheduled:completed', (e) => { + addListener('scheduled:completed', (e) => { this.currentRun = JSON.parse(e.data); this.hideTimer(); this.showToast('Scheduled run completed!', 'success'); }); - this.eventSource.addEventListener('scheduled:stopped', (e) => { + addListener('scheduled:stopped', (e) => { this.currentRun = null; this.hideTimer(); }); // Respawn events - this.eventSource.addEventListener('respawn:started', (e) => { + addListener('respawn:started', (e) => { const data = JSON.parse(e.data); this.respawnStatus[data.sessionId] = data.status; if (data.sessionId === this.activeSessionId) { @@ -1288,7 +1314,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:stopped', (e) => { + addListener('respawn:stopped', (e) => { const data = JSON.parse(e.data); delete this.respawnStatus[data.sessionId]; if (data.sessionId === this.activeSessionId) { @@ -1296,7 +1322,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:stateChanged', (e) => { + addListener('respawn:stateChanged', (e) => { const data = JSON.parse(e.data); if (this.respawnStatus[data.sessionId]) { this.respawnStatus[data.sessionId].state = data.state; @@ -1306,7 +1332,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:cycleStarted', (e) => { + addListener('respawn:cycleStarted', (e) => { const data = JSON.parse(e.data); if (this.respawnStatus[data.sessionId]) { this.respawnStatus[data.sessionId].cycleCount = data.cycleNumber; @@ -1316,7 +1342,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:blocked', (e) => { + addListener('respawn:blocked', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.sessionId); const reasonMap = { @@ -1343,11 +1369,11 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:stepSent', (_e) => { + addListener('respawn:stepSent', (_e) => { // Step info is shown via state label (e.g., "Sending prompt", "Clearing context") }); - this.eventSource.addEventListener('respawn:autoAcceptSent', (e) => { + addListener('respawn:autoAcceptSent', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.sessionId); this.notificationManager?.notify({ @@ -1360,7 +1386,7 @@ class ClaudemanApp { }); }); - this.eventSource.addEventListener('respawn:detectionUpdate', (e) => { + addListener('respawn:detectionUpdate', (e) => { const data = JSON.parse(e.data); if (this.respawnStatus[data.sessionId]) { this.respawnStatus[data.sessionId].detection = data.detection; @@ -1370,24 +1396,24 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:aiCheckStarted', (_e) => { + addListener('respawn:aiCheckStarted', (_e) => { // AI check status shown via updateDetectionDisplay }); - this.eventSource.addEventListener('respawn:aiCheckCompleted', (_e) => { + addListener('respawn:aiCheckCompleted', (_e) => { // AI check status shown via updateDetectionDisplay }); - this.eventSource.addEventListener('respawn:aiCheckFailed', (_e) => { + addListener('respawn:aiCheckFailed', (_e) => { // AI check status shown via updateDetectionDisplay }); - this.eventSource.addEventListener('respawn:aiCheckCooldown', (_e) => { + addListener('respawn:aiCheckCooldown', (_e) => { // AI check status shown via updateDetectionDisplay }); // Respawn run timer events (timed respawn runs) - this.eventSource.addEventListener('respawn:timerStarted', (e) => { + addListener('respawn:timerStarted', (e) => { const data = JSON.parse(e.data); this.respawnTimers[data.sessionId] = { endAt: data.endAt, @@ -1400,7 +1426,7 @@ class ClaudemanApp { }); // Respawn controller countdown timer events (internal timers) - this.eventSource.addEventListener('respawn:timerStarted', (e) => { + addListener('respawn:timerStarted', (e) => { const data = JSON.parse(e.data); // This may fire for both run timers and controller timers - check for timer object if (data.timer) { @@ -1420,7 +1446,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:timerCancelled', (e) => { + addListener('respawn:timerCancelled', (e) => { const data = JSON.parse(e.data); const { sessionId, timerName } = data; if (this.respawnCountdownTimers[sessionId]) { @@ -1431,7 +1457,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:timerCompleted', (e) => { + addListener('respawn:timerCompleted', (e) => { const data = JSON.parse(e.data); const { sessionId, timerName } = data; if (this.respawnCountdownTimers[sessionId]) { @@ -1442,7 +1468,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:actionLog', (e) => { + addListener('respawn:actionLog', (e) => { const data = JSON.parse(e.data); const { sessionId, action } = data; this.addActionLogEntry(sessionId, action); @@ -1453,7 +1479,7 @@ class ClaudemanApp { }); // Auto-clear event - this.eventSource.addEventListener('session:autoClear', (e) => { + addListener('session:autoClear', (e) => { const data = JSON.parse(e.data); if (data.sessionId === this.activeSessionId) { this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info'); @@ -1471,7 +1497,7 @@ class ClaudemanApp { }); // Background task events - this.eventSource.addEventListener('task:created', (e) => { + addListener('task:created', (e) => { const data = JSON.parse(e.data); this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { @@ -1479,7 +1505,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('task:completed', (e) => { + addListener('task:completed', (e) => { const data = JSON.parse(e.data); this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { @@ -1487,7 +1513,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('task:failed', (e) => { + addListener('task:failed', (e) => { const data = JSON.parse(e.data); this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { @@ -1495,7 +1521,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('task:updated', (e) => { + addListener('task:updated', (e) => { const data = JSON.parse(e.data); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); @@ -1503,26 +1529,26 @@ class ClaudemanApp { }); // Screen events - this.eventSource.addEventListener('screen:created', (e) => { + addListener('screen:created', (e) => { const screen = JSON.parse(e.data); this.screenSessions.push(screen); this.renderScreenSessions(); }); - this.eventSource.addEventListener('screen:killed', (e) => { + addListener('screen:killed', (e) => { const data = JSON.parse(e.data); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); this.renderScreenSessions(); }); - this.eventSource.addEventListener('screen:died', (e) => { + addListener('screen:died', (e) => { const data = JSON.parse(e.data); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); this.renderScreenSessions(); this.showToast('Screen session died: ' + data.sessionId.slice(0, 8), 'warning'); }); - this.eventSource.addEventListener('screen:statsUpdated', (e) => { + addListener('screen:statsUpdated', (e) => { this.screenSessions = JSON.parse(e.data); if (document.getElementById('monitorPanel').classList.contains('open')) { this.renderScreenSessions(); @@ -1530,17 +1556,17 @@ class ClaudemanApp { }); // Ralph loop/todo events - this.eventSource.addEventListener('session:ralphLoopUpdate', (e) => { + addListener('session:ralphLoopUpdate', (e) => { const data = JSON.parse(e.data); this.updateRalphState(data.sessionId, { loop: data.state }); }); - this.eventSource.addEventListener('session:ralphTodoUpdate', (e) => { + addListener('session:ralphTodoUpdate', (e) => { const data = JSON.parse(e.data); this.updateRalphState(data.sessionId, { todos: data.todos }); }); - this.eventSource.addEventListener('session:ralphCompletionDetected', (e) => { + addListener('session:ralphCompletionDetected', (e) => { const data = JSON.parse(e.data); // Prevent duplicate notifications for the same completion const completionKey = `${data.sessionId}:${data.phrase}`; @@ -1573,12 +1599,12 @@ class ClaudemanApp { }); // RALPH_STATUS block and circuit breaker events - this.eventSource.addEventListener('session:ralphStatusUpdate', (e) => { + addListener('session:ralphStatusUpdate', (e) => { const data = JSON.parse(e.data); this.updateRalphState(data.sessionId, { statusBlock: data.block }); }); - this.eventSource.addEventListener('session:circuitBreakerUpdate', (e) => { + addListener('session:circuitBreakerUpdate', (e) => { const data = JSON.parse(e.data); this.updateRalphState(data.sessionId, { circuitBreaker: data.status }); // Notify if circuit breaker opens @@ -1595,7 +1621,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('session:exitGateMet', (e) => { + addListener('session:exitGateMet', (e) => { const data = JSON.parse(e.data); // Notify when exit gate is met const session = this.sessions.get(data.sessionId); @@ -1610,23 +1636,23 @@ class ClaudemanApp { }); // Active Bash tool events (for clickable file paths) - this.eventSource.addEventListener('session:bashToolStart', (e) => { + addListener('session:bashToolStart', (e) => { const data = JSON.parse(e.data); this.handleBashToolStart(data.sessionId, data.tool); }); - this.eventSource.addEventListener('session:bashToolEnd', (e) => { + addListener('session:bashToolEnd', (e) => { const data = JSON.parse(e.data); this.handleBashToolEnd(data.sessionId, data.tool); }); - this.eventSource.addEventListener('session:bashToolsUpdate', (e) => { + addListener('session:bashToolsUpdate', (e) => { const data = JSON.parse(e.data); this.handleBashToolsUpdate(data.sessionId, data.tools); }); // Spawn agent notification events - this.eventSource.addEventListener('spawn:failed', (e) => { + addListener('spawn:failed', (e) => { const data = JSON.parse(e.data); this.notificationManager?.notify({ urgency: 'critical', @@ -1638,7 +1664,7 @@ class ClaudemanApp { }); }); - this.eventSource.addEventListener('spawn:timeout', (e) => { + addListener('spawn:timeout', (e) => { const data = JSON.parse(e.data); this.notificationManager?.notify({ urgency: 'critical', @@ -1650,7 +1676,7 @@ class ClaudemanApp { }); }); - this.eventSource.addEventListener('spawn:budgetWarning', (e) => { + addListener('spawn:budgetWarning', (e) => { const data = JSON.parse(e.data); this.notificationManager?.notify({ urgency: 'warning', @@ -1662,7 +1688,7 @@ class ClaudemanApp { }); }); - this.eventSource.addEventListener('spawn:completed', (e) => { + addListener('spawn:completed', (e) => { const data = JSON.parse(e.data); this.notificationManager?.notify({ urgency: 'info', @@ -1677,7 +1703,7 @@ class ClaudemanApp { // Hook events (from Claude Code hooks system) // Use pendingHooks state machine to track hook events and derive tab alerts. // This ensures alerts persist even when session:working events fire. - this.eventSource.addEventListener('hook:idle_prompt', (e) => { + addListener('hook:idle_prompt', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.sessionId); // Always track pending hook - alert will show when switching away from session @@ -1694,7 +1720,7 @@ class ClaudemanApp { }); }); - this.eventSource.addEventListener('hook:permission_prompt', (e) => { + addListener('hook:permission_prompt', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.sessionId); // Always track pending hook - action alerts need user interaction to clear @@ -1712,7 +1738,7 @@ class ClaudemanApp { }); }); - this.eventSource.addEventListener('hook:elicitation_dialog', (e) => { + addListener('hook:elicitation_dialog', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.sessionId); // Always track pending hook - action alerts need user interaction to clear @@ -1729,7 +1755,7 @@ class ClaudemanApp { }); }); - this.eventSource.addEventListener('hook:stop', (e) => { + addListener('hook:stop', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.sessionId); // Clear all pending hooks when Claude finishes responding @@ -1748,7 +1774,7 @@ class ClaudemanApp { // ========== Subagent Events (Claude Code Background Agents) ========== - this.eventSource.addEventListener('subagent:discovered', async (e) => { + addListener('subagent:discovered', async (e) => { const data = JSON.parse(e.data); this.subagents.set(data.agentId, data); this.subagentActivity.set(data.agentId, []); @@ -1763,7 +1789,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('subagent:updated', (e) => { + addListener('subagent:updated', (e) => { const data = JSON.parse(e.data); const existing = this.subagents.get(data.agentId); if (existing) { @@ -1781,7 +1807,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('subagent:tool_call', (e) => { + addListener('subagent:tool_call', (e) => { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'tool', ...data }); @@ -1797,7 +1823,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('subagent:progress', (e) => { + addListener('subagent:progress', (e) => { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'progress', ...data }); @@ -1812,7 +1838,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('subagent:message', (e) => { + addListener('subagent:message', (e) => { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'message', ...data }); @@ -1827,7 +1853,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('subagent:tool_result', (e) => { + addListener('subagent:tool_result', (e) => { const data = JSON.parse(e.data); // Store tool result by toolUseId for later lookup if (!this.subagentToolResults.has(data.agentId)) { @@ -1850,7 +1876,7 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('subagent:completed', async (e) => { + addListener('subagent:completed', async (e) => { const data = JSON.parse(e.data); const existing = this.subagents.get(data.agentId); if (existing) { @@ -1882,7 +1908,7 @@ class ClaudemanApp { }); // Plan generation progress events (for detailed mode with subagents) - this.eventSource.addEventListener('plan:progress', (e) => { + addListener('plan:progress', (e) => { const data = JSON.parse(e.data); console.log('[Plan Progress]', data); @@ -1944,6 +1970,24 @@ class ClaudemanApp { if (this._shownCompletions) { this._shownCompletions.clear(); } + // Clear notification manager title flash interval to prevent memory leak + if (this.notificationManager?.titleFlashInterval) { + clearInterval(this.notificationManager.titleFlashInterval); + this.notificationManager.titleFlashInterval = null; + } + // Clear any other orphaned timers + if (this.planLoadingTimer) { + clearInterval(this.planLoadingTimer); + this.planLoadingTimer = null; + } + if (this.timerCountdownInterval) { + clearInterval(this.timerCountdownInterval); + this.timerCountdownInterval = null; + } + if (this.runSummaryAutoRefreshTimer) { + clearInterval(this.runSummaryAutoRefreshTimer); + this.runSummaryAutoRefreshTimer = null; + } data.sessions.forEach(s => { this.sessions.set(s.id, s); // Load ralph state from session data @@ -1983,6 +2027,10 @@ class ClaudemanApp { this.updateCost(); this.renderSessionTabs(); + // CRITICAL: Clean up all floating windows before loading new subagents + // This prevents memory leaks from ResizeObservers, EventSources, and DOM elements + this.cleanupAllFloatingWindows(); + // Load subagents - clear all related maps to prevent memory leaks on reconnect if (data.subagents) { this.subagents.clear(); @@ -3112,7 +3160,6 @@ class ClaudemanApp { showPlanError(message) { document.getElementById('planGenerationLoading')?.classList.add('hidden'); - document.getElementById('planGenerationControls')?.classList.add('hidden'); document.getElementById('planEditor')?.classList.add('hidden'); const errorEl = document.getElementById('planGenerationError'); @@ -3121,9 +3168,11 @@ class ClaudemanApp { errorEl?.classList.remove('hidden'); } + // Plan view mode: 'list' (flat) or 'grouped' (by TDD phase) + planViewMode = 'list'; + renderPlanEditor() { document.getElementById('planGenerationLoading')?.classList.add('hidden'); - document.getElementById('planGenerationControls')?.classList.add('hidden'); document.getElementById('planGenerationError')?.classList.add('hidden'); document.getElementById('planEditor')?.classList.remove('hidden'); @@ -3177,20 +3226,137 @@ class ClaudemanApp { } } - items.forEach((item, index) => { - const row = this.renderPlanItem(item, index); - list.appendChild(row); + // Render based on view mode - use DocumentFragment for batch DOM updates + if (this.planViewMode === 'grouped' && items.some(i => i.tddPhase)) { + this.renderPlanItemsGrouped(list, items); + } else { + // Flat list view with DocumentFragment for performance + const fragment = document.createDocumentFragment(); + items.forEach((item, index) => { + const row = this.renderPlanItem(item, index); + fragment.appendChild(row); + }); + list.appendChild(fragment); + } + } + + // Render plan items grouped by TDD phase - uses DocumentFragment for batch DOM updates + renderPlanItemsGrouped(container, items) { + const phases = ['setup', 'test', 'impl', 'review', 'verify', null]; + const phaseLabels = { + setup: { label: 'Setup', icon: '🔧' }, + test: { label: 'Tests', icon: '🧪' }, + impl: { label: 'Implementation', icon: '💻' }, + review: { label: 'Code Review', icon: '🔍' }, + verify: { label: 'Verification', icon: '✅' }, + null: { label: 'Other', icon: '📋' }, + }; + + // First, create indexed items to preserve original positions (non-mutating) + const indexedItems = items.map((item, idx) => ({ ...item, _originalIndex: idx })); + + // Build all DOM elements in a DocumentFragment first (no reflows) + const fragment = document.createDocumentFragment(); + + phases.forEach(phase => { + // Filter using indexed items + const phaseItems = indexedItems.filter(item => (item.tddPhase || null) === phase); + + if (phaseItems.length === 0) return; + + const phaseKey = phase || 'null'; + const { label, icon } = phaseLabels[phaseKey]; + + // Create group header + const header = document.createElement('div'); + header.className = 'plan-group-header'; + header.dataset.phase = phaseKey; + header.innerHTML = ` + ${icon} + ${label} + ${phaseItems.length} + ▼ + `; + header.onclick = () => this.togglePlanGroup(phaseKey); + fragment.appendChild(header); + + // Create group items container + const groupItems = document.createElement('div'); + groupItems.className = 'plan-group-items'; + groupItems.dataset.phase = phaseKey; + + phaseItems.forEach(item => { + const row = this.renderPlanItem(item, item._originalIndex); + groupItems.appendChild(row); + }); + + fragment.appendChild(groupItems); }); + + // Single DOM operation - append all at once + container.appendChild(fragment); + } + + togglePlanGroup(phase) { + const header = document.querySelector(`.plan-group-header[data-phase="${phase}"]`); + const items = document.querySelector(`.plan-group-items[data-phase="${phase}"]`); + + if (header && items) { + header.classList.toggle('collapsed'); + items.classList.toggle('collapsed'); + } + } + + setPlanViewMode(mode) { + this.planViewMode = mode; + + // Update view toggle buttons + document.querySelectorAll('.plan-view-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.view === mode); + }); + + this.renderPlanEditor(); } renderPlanItem(item, index) { const row = document.createElement('div'); - row.className = 'plan-item'; + + // Build class with priority indicator + let className = 'plan-item'; + if (item.priority) { + className += ` priority-${item.priority.toLowerCase()}`; + } + row.className = className; row.dataset.index = index; + row.draggable = true; + + // Add TDD phase badge with enhanced styling + const phaseIcon = this.getTddPhaseIcon(item.tddPhase); + const phaseClass = item.tddPhase ? `phase-${item.tddPhase}` : ''; + const phaseTitle = item.tddPhase ? `TDD Phase: ${item.tddPhase}` : ''; + + // Build verification tooltip + const verificationTitle = item.verificationCriteria + ? `Verification: ${item.verificationCriteria}` + : ''; + + // Dependencies indicator + const depsCount = item.dependencies?.length || 0; + const depsTitle = depsCount > 0 + ? `Depends on: ${item.dependencies.join(', ')}` + : ''; + + // Review checklist indicator for review phase tasks + const checklistCount = item.reviewChecklist?.length || 0; + const checklistTitle = checklistCount > 0 + ? `Review Checklist:\n• ${item.reviewChecklist.join('\n• ')}` + : ''; row.innerHTML = ` + ☰ + ${phaseIcon ? `${phaseIcon}` : ''} @@ -3200,12 +3366,148 @@ class ClaudemanApp { + ${depsCount > 0 ? `↗ ${depsCount}` : ''} + ${item.verificationCriteria ? `✓ verify` : ''} + ${checklistCount > 0 ? `☑ ${checklistCount}` : ''} +
+ + ${checklistCount > 0 ? `` : ''} + +
`; + // Add drag-and-drop event handlers + row.addEventListener('dragstart', (e) => this.handlePlanItemDragStart(e, index)); + row.addEventListener('dragend', (e) => this.handlePlanItemDragEnd(e)); + row.addEventListener('dragover', (e) => this.handlePlanItemDragOver(e)); + row.addEventListener('drop', (e) => this.handlePlanItemDrop(e, index)); + return row; } + // Drag and drop handlers for plan items + handlePlanItemDragStart(e, index) { + this._draggedPlanIndex = index; + e.target.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', index); + } + + handlePlanItemDragEnd(e) { + e.target.classList.remove('dragging'); + // Remove drag-over class from all items + document.querySelectorAll('.plan-item.drag-over').forEach(el => { + el.classList.remove('drag-over'); + }); + this._draggedPlanIndex = null; + } + + handlePlanItemDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + const item = e.target.closest('.plan-item'); + if (item && !item.classList.contains('dragging')) { + // Remove drag-over from other items + document.querySelectorAll('.plan-item.drag-over').forEach(el => { + if (el !== item) el.classList.remove('drag-over'); + }); + item.classList.add('drag-over'); + } + } + + handlePlanItemDrop(e, targetIndex) { + e.preventDefault(); + const sourceIndex = this._draggedPlanIndex; + + if (sourceIndex !== null && sourceIndex !== targetIndex) { + const plan = this.ralphWizardConfig.generatedPlan; + if (plan) { + // Move the item + const [movedItem] = plan.splice(sourceIndex, 1); + plan.splice(targetIndex, 0, movedItem); + this.renderPlanEditor(); + } + } + } + + // Edit verification criteria inline + editPlanItemVerification(index) { + const plan = this.ralphWizardConfig.generatedPlan; + if (!plan || !plan[index]) return; + + const currentCriteria = plan[index].verificationCriteria || ''; + const newCriteria = prompt('Verification criteria:', currentCriteria); + + if (newCriteria !== null) { + plan[index].verificationCriteria = newCriteria.trim() || undefined; + this.renderPlanEditor(); + } + } + + // Duplicate a plan item + duplicatePlanItem(index) { + const plan = this.ralphWizardConfig.generatedPlan; + if (!plan || !plan[index]) return; + + const original = plan[index]; + const duplicate = { + ...original, + id: `plan-${Date.now()}-dup`, + content: original.content + ' (copy)', + }; + plan.splice(index + 1, 0, duplicate); + this.renderPlanEditor(); + } + + showReviewChecklist(index) { + const plan = this.ralphWizardConfig.generatedPlan; + if (!plan || !plan[index]) return; + + const item = plan[index]; + if (!item.reviewChecklist || item.reviewChecklist.length === 0) return; + + // Create modal content + const checklistHtml = item.reviewChecklist.map((check, i) => ` +
  • + + +
  • + `).join(''); + + const content = ` +
    +

    Review Checklist

    +

    ${this.escapeHtml(item.content)}

    +
      + ${checklistHtml} +
    + +
    + `; + + // Show in a modal + const modal = document.createElement('div'); + modal.className = 'modal checklist-modal-container'; + modal.innerHTML = ``; + modal.onclick = (e) => { if (e.target === modal) modal.remove(); }; + document.body.appendChild(modal); + } + + getTddPhaseIcon(phase) { + switch (phase) { + case 'setup': return '🔧'; + case 'test': return '🧪'; + case 'impl': return '💻'; + case 'verify': return '✅'; + case 'review': return '🔍'; + default: return ''; + } + } + escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; @@ -3261,6 +3563,17 @@ class ClaudemanApp { }, 50); } + cancelPlanGeneration() { + this.stopPlanGeneration(); + this.showToast('Plan generation cancelled', 'info'); + + const errorEl = document.getElementById('planGenerationError'); + const msgEl = document.getElementById('planErrorMsg'); + if (msgEl) msgEl.textContent = 'Plan generation was cancelled.'; + errorEl?.classList.remove('hidden'); + document.getElementById('planGenerationLoading')?.classList.add('hidden'); + } + skipPlanGeneration() { // Stop any running timers if (this.planLoadingTimer) { @@ -6609,6 +6922,13 @@ class ClaudemanApp { tasksCountEl.textContent = `${completed}/${todos.length}`; } + // Update plan version display if available + if (state?.loop?.planVersion) { + this.updatePlanVersionDisplay(state.loop.planVersion, state.loop.planHistoryLength || 1); + } else { + this.updatePlanVersionDisplay(null, 0); + } + // Render task cards this.renderRalphTasks(todos); @@ -6697,85 +7017,161 @@ class ClaudemanApp { return (statusOrder[a.status] || 1) - (statusOrder[b.status] || 1); }); - // Incremental DOM update - reuse existing elements where possible - const existingCards = grid.querySelectorAll('.ralph-task-card'); + // Always do full rebuild for enhanced features const fragment = document.createDocumentFragment(); - let needsRebuild = existingCards.length !== sorted.length; - // Check if we can do incremental update - if (!needsRebuild) { - // Update existing cards in place - sorted.forEach((todo, i) => { - const card = existingCards[i]; - const statusClass = `task-${todo.status.replace('_', '-')}`; - const priorityClass = todo.priority ? `task-priority-${todo.priority.toLowerCase()}` : ''; - const icon = this.getRalphTaskIcon(todo.status); + sorted.forEach((todo, idx) => { + const card = this.createRalphTaskCard(todo, idx); + fragment.appendChild(card); + }); - // Update class if changed - const newClass = `ralph-task-card ${statusClass} ${priorityClass}`.trim(); - if (card.className !== newClass) { - card.className = newClass; - } + grid.innerHTML = ''; + grid.appendChild(fragment); + } - // Update icon if changed - const iconEl = card.querySelector('.ralph-task-icon'); - if (iconEl && iconEl.textContent !== icon) { - iconEl.textContent = icon; - } + createRalphTaskCard(todo, index) { + const card = document.createElement('div'); + const statusClass = `task-${todo.status.replace('_', '-')}`; + const priorityClass = todo.priority ? `task-priority-${todo.priority.toLowerCase()}` : ''; + card.className = `ralph-task-card ${statusClass} ${priorityClass}`.trim(); + card.dataset.taskId = todo.id || index; - // Update priority badge - let badgeEl = card.querySelector('.ralph-task-priority'); - if (todo.priority) { - if (!badgeEl) { - badgeEl = document.createElement('span'); - badgeEl.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`; - card.insertBefore(badgeEl, card.querySelector('.ralph-task-content')); - } - if (badgeEl.textContent !== todo.priority) { - badgeEl.textContent = todo.priority; - badgeEl.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`; - } - } else if (badgeEl) { - badgeEl.remove(); - } + // Status icon + const iconSpan = document.createElement('span'); + iconSpan.className = 'ralph-task-icon'; + iconSpan.textContent = this.getRalphTaskIcon(todo.status); + card.appendChild(iconSpan); - // Update content if changed - const contentEl = card.querySelector('.ralph-task-content'); - if (contentEl && contentEl.textContent !== todo.content) { - contentEl.textContent = todo.content; - } - }); - } else { - // Full rebuild needed - use DocumentFragment for efficiency - sorted.forEach(todo => { - const card = document.createElement('div'); - const statusClass = `task-${todo.status.replace('_', '-')}`; - const priorityClass = todo.priority ? `task-priority-${todo.priority.toLowerCase()}` : ''; - card.className = `ralph-task-card ${statusClass} ${priorityClass}`.trim(); + // Priority badge if present + if (todo.priority) { + const prioritySpan = document.createElement('span'); + prioritySpan.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`; + prioritySpan.textContent = todo.priority; + card.appendChild(prioritySpan); + } - const iconSpan = document.createElement('span'); - iconSpan.className = 'ralph-task-icon'; - iconSpan.textContent = this.getRalphTaskIcon(todo.status); - card.appendChild(iconSpan); + // Task content + const contentSpan = document.createElement('span'); + contentSpan.className = 'ralph-task-content'; + contentSpan.textContent = todo.content; + card.appendChild(contentSpan); - // Add priority badge if present - if (todo.priority) { - const prioritySpan = document.createElement('span'); - prioritySpan.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`; - prioritySpan.textContent = todo.priority; - card.appendChild(prioritySpan); - } + // Attempts indicator (if > 0) + if (todo.attempts && todo.attempts > 0) { + const attemptsSpan = document.createElement('span'); + attemptsSpan.className = 'ralph-task-attempts'; + if (todo.lastError) { + attemptsSpan.classList.add('has-errors'); + attemptsSpan.title = `Last error: ${todo.lastError}`; + } + attemptsSpan.textContent = `#${todo.attempts}`; + card.appendChild(attemptsSpan); + } - const contentSpan = document.createElement('span'); - contentSpan.className = 'ralph-task-content'; - contentSpan.textContent = todo.content; - card.appendChild(contentSpan); + // Verification badge (if has verification criteria) + if (todo.verificationCriteria) { + const verifySpan = document.createElement('span'); + verifySpan.className = 'ralph-task-verify-badge'; + verifySpan.title = `Verify: ${todo.verificationCriteria}`; + verifySpan.textContent = '✓'; + card.appendChild(verifySpan); + } - fragment.appendChild(card); + // Dependencies indicator + if (todo.dependencies && todo.dependencies.length > 0) { + const depsSpan = document.createElement('span'); + depsSpan.className = 'ralph-task-deps-indicator'; + depsSpan.title = `Depends on: ${todo.dependencies.join(', ')}`; + depsSpan.textContent = `↗${todo.dependencies.length}`; + card.appendChild(depsSpan); + } + + // Quick action buttons (shown on hover) + const actions = document.createElement('div'); + actions.className = 'ralph-task-actions'; + + if (todo.status !== 'completed') { + const completeBtn = document.createElement('button'); + completeBtn.className = 'ralph-task-action-btn'; + completeBtn.textContent = '✓'; + completeBtn.title = 'Mark complete'; + completeBtn.onclick = (e) => { + e.stopPropagation(); + this.updateRalphTaskStatus(todo.id, 'completed'); + }; + actions.appendChild(completeBtn); + } + + if (todo.status === 'completed') { + const reopenBtn = document.createElement('button'); + reopenBtn.className = 'ralph-task-action-btn'; + reopenBtn.textContent = '↺'; + reopenBtn.title = 'Reopen'; + reopenBtn.onclick = (e) => { + e.stopPropagation(); + this.updateRalphTaskStatus(todo.id, 'pending'); + }; + actions.appendChild(reopenBtn); + } + + if (todo.lastError) { + const retryBtn = document.createElement('button'); + retryBtn.className = 'ralph-task-action-btn'; + retryBtn.textContent = '↻'; + retryBtn.title = 'Retry (clear error)'; + retryBtn.onclick = (e) => { + e.stopPropagation(); + this.retryRalphTask(todo.id); + }; + actions.appendChild(retryBtn); + } + + card.appendChild(actions); + + return card; + } + + // Update a Ralph task's status via API + async updateRalphTaskStatus(taskId, newStatus) { + if (!this.activeSessionId) return; + + try { + const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/task/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }) }); - grid.innerHTML = ''; - grid.appendChild(fragment); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to update task'); + } + + this.showToast(`Task ${newStatus === 'completed' ? 'completed' : 'reopened'}`, 'success'); + } catch (err) { + this.showToast('Failed to update task: ' + err.message, 'error'); + } + } + + // Retry a failed Ralph task (clear error, reset attempts) + async retryRalphTask(taskId) { + if (!this.activeSessionId) return; + + try { + const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/task/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ attempts: 0, lastError: null, status: 'pending' }) + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to retry task'); + } + + this.showToast('Task reset for retry', 'success'); + } catch (err) { + this.showToast('Failed to retry task: ' + err.message, 'error'); } } @@ -6793,6 +7189,146 @@ class ClaudemanApp { return this.getRalphTaskIcon(status); } + // ========== Plan Versioning ========== + + // Update the plan version display in the Ralph panel + updatePlanVersionDisplay(version, historyLength) { + const versionRow = this.$('ralphVersionRow'); + const versionBadge = this.$('ralphPlanVersion'); + const rollbackBtn = this.$('ralphRollbackBtn'); + + if (!versionRow) return; + + if (version && version > 0) { + versionRow.style.display = ''; + if (versionBadge) versionBadge.textContent = `v${version}`; + if (rollbackBtn) { + rollbackBtn.style.display = historyLength > 1 ? '' : 'none'; + } + } else { + versionRow.style.display = 'none'; + } + } + + // Show plan history dropdown + async showPlanHistory() { + if (!this.activeSessionId) return; + + try { + const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/history`); + const data = await res.json(); + + if (data.error) { + this.showToast('Failed to load plan history: ' + data.error, 'error'); + return; + } + + const history = data.history || []; + if (history.length === 0) { + this.showToast('No plan history available', 'info'); + return; + } + + // Show history dropdown modal + this.showPlanHistoryModal(history, data.currentVersion); + } catch (err) { + this.showToast('Failed to load plan history: ' + err.message, 'error'); + } + } + + // Show the plan history modal + showPlanHistoryModal(history, currentVersion) { + // Remove existing modal if present + const existing = document.getElementById('planHistoryModal'); + if (existing) existing.remove(); + + const modal = document.createElement('div'); + modal.id = 'planHistoryModal'; + modal.className = 'modal active'; + modal.innerHTML = ` + + + `; + + document.body.appendChild(modal); + } + + closePlanHistoryModal() { + const modal = document.getElementById('planHistoryModal'); + if (modal) modal.remove(); + } + + // Rollback to a specific plan version + async rollbackToPlanVersion(version) { + if (!this.activeSessionId) return; + + if (!confirm(`Rollback to plan version ${version}? Current changes will be preserved in history.`)) { + return; + } + + try { + const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/rollback/${version}`, { + method: 'POST' + }); + const data = await res.json(); + + if (data.error) { + this.showToast('Failed to rollback: ' + data.error, 'error'); + return; + } + + this.showToast(`Rolled back to plan v${version}`, 'success'); + this.closePlanHistoryModal(); + + // Refresh the plan display + this.renderRalphStatePanel(); + } catch (err) { + this.showToast('Failed to rollback: ' + err.message, 'error'); + } + } + + // Format relative time (e.g., "2 mins ago", "1 hour ago") + formatRelativeTime(timestamp) { + if (!timestamp) return ''; + + const now = Date.now(); + const diff = now - timestamp; + + const mins = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + if (hours < 24) return `${hours}h ago`; + return `${days}d ago`; + } + // ========== Subagent Panel (Claude Code Background Agents) ========== // Legacy alias @@ -7411,8 +7947,8 @@ class ClaudemanApp { document.body.appendChild(win); - // Make draggable (with connection line update callback) - this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); + // Make draggable (returns listener refs for cleanup) + const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); // Check if this window should be visible based on settings const settings = this.loadAppSettingsFromStorage(); @@ -7424,11 +7960,12 @@ class ClaudemanApp { shouldHide = !isForActiveSession; } - // Store reference + // Store reference (including drag listeners for cleanup) this.subagentWindows.set(agentId, { element: win, minimized: false, hidden: shouldHide, + dragListeners, // Store for cleanup to prevent memory leaks }); // Hide window if not for active session @@ -7546,11 +8083,51 @@ class ClaudemanApp { if (windowData.resizeObserver) { windowData.resizeObserver.disconnect(); } + // Clean up global drag event listeners (prevents memory leak) + if (windowData.dragListeners) { + document.removeEventListener('mousemove', windowData.dragListeners.move); + document.removeEventListener('mouseup', windowData.dragListeners.up); + } windowData.element.remove(); this.subagentWindows.delete(agentId); } } + // Clean up ALL floating windows (called during handleInit to prevent memory leaks on reconnect) + cleanupAllFloatingWindows() { + // Clean up all subagent windows with their ResizeObservers and drag listeners + for (const [agentId, windowData] of this.subagentWindows) { + if (windowData.resizeObserver) { + windowData.resizeObserver.disconnect(); + } + if (windowData.dragListeners) { + document.removeEventListener('mousemove', windowData.dragListeners.move); + document.removeEventListener('mouseup', windowData.dragListeners.up); + } + windowData.element.remove(); + } + this.subagentWindows.clear(); + + // Clean up all log viewer windows with their EventSources and drag listeners + for (const [windowId, data] of this.logViewerWindows) { + if (data.eventSource) { + data.eventSource.close(); + } + if (data.dragListeners) { + document.removeEventListener('mousemove', data.dragListeners.move); + document.removeEventListener('mouseup', data.dragListeners.up); + } + data.element.remove(); + } + this.logViewerWindows.clear(); + + // Clear minimized agents tracking + this.minimizedSubagents.clear(); + + // Update connection lines (should be empty now) + this.updateConnectionLines(); + } + minimizeSubagentWindow(agentId) { const windowData = this.subagentWindows.get(agentId); if (windowData) { @@ -7591,6 +8168,7 @@ class ClaudemanApp { } } + // Returns drag listener references for cleanup (prevents memory leaks) makeWindowDraggable(win, handle) { let isDragging = false; let startX, startY, startLeft, startTop; @@ -7606,7 +8184,8 @@ class ClaudemanApp { e.preventDefault(); }); - document.addEventListener('mousemove', (e) => { + // Store references to document-level listeners so they can be removed on window close + const moveListener = (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; @@ -7627,15 +8206,21 @@ class ClaudemanApp { dragUpdateScheduled = false; }); } - }); + }; - document.addEventListener('mouseup', () => { + const upListener = () => { if (isDragging) { isDragging = false; // Save position after drag ends this.saveSubagentWindowStates(); } - }); + }; + + document.addEventListener('mousemove', moveListener); + document.addEventListener('mouseup', upListener); + + // Return listener references for cleanup + return { move: moveListener, up: upListener }; } renderSubagentWindowContent(agentId) { @@ -8424,8 +9009,8 @@ class ClaudemanApp { document.body.appendChild(win); - // Make draggable - this.makeWindowDraggable(win, win.querySelector('.log-viewer-window-header')); + // Make draggable (returns listener refs for cleanup) + const dragListeners = this.makeWindowDraggable(win, win.querySelector('.log-viewer-window-header')); // Connect to SSE stream const eventSource = new EventSource( @@ -8468,12 +9053,13 @@ class ClaudemanApp { this.updateLogViewerStatus(windowId, 'disconnected', 'connection error'); }; - // Store reference + // Store reference (including drag listeners for cleanup) this.logViewerWindows.set(windowId, { element: win, eventSource, filePath, sessionId, + dragListeners, // Store for cleanup to prevent memory leaks }); } @@ -8494,6 +9080,12 @@ class ClaudemanApp { windowData.eventSource.close(); } + // Clean up global drag event listeners (prevents memory leak) + if (windowData.dragListeners) { + document.removeEventListener('mousemove', windowData.dragListeners.move); + document.removeEventListener('mouseup', windowData.dragListeners.up); + } + // Remove element windowData.element.remove(); diff --git a/src/web/server.ts b/src/web/server.ts index 47d2efd2..0bf96817 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2197,7 +2197,10 @@ export class WebServer extends EventEmitter { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(settingsFilePath, JSON.stringify(settings, null, 2)); + // Use async write to avoid blocking event loop + fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch(() => { + // Non-critical, ignore settings save errors + }); } catch { // Non-critical, ignore settings save errors } @@ -2221,10 +2224,8 @@ export class WebServer extends EventEmitter { detailLevel?: 'brief' | 'standard' | 'detailed'; } - interface PlanItem { - content: string; - priority: 'P0' | 'P1' | 'P2' | null; - } + // Use enhanced PlanItem from orchestrator (has verification, dependencies, tracking) + type PlanItem = import('../plan-orchestrator.js').PlanItem; this.app.post('/api/generate-plan', async (req): Promise => { const { @@ -2294,34 +2295,35 @@ For EACH feature: - Final verification that ALL requirements are met ## OUTPUT FORMAT -Return ONLY a JSON array. Each item: +Return ONLY a JSON array. Each item MUST have: +- id: unique identifier (e.g., "P0-001", "P1-002") - content: specific action (verb phrase, 15-120 chars, be descriptive!) - priority: "P0" (critical/blocking), "P1" (required), "P2" (enhancement) +- verificationCriteria: HOW to verify this step is complete (required!) +- tddPhase: "setup" | "test" | "impl" | "verify" +- dependencies: array of task IDs this depends on (empty if none) ## EXAMPLE OUTPUT [ - {"content": "Create project structure with src/, tests/, and config directories", "priority": "P0"}, - {"content": "Define TypeScript interfaces for User, Session, and AuthToken types", "priority": "P0"}, - {"content": "Write failing unit tests for password hashing (valid password, empty, too short)", "priority": "P0"}, - {"content": "Implement password hashing with bcrypt, configurable salt rounds", "priority": "P0"}, - {"content": "Run password tests and debug until all pass", "priority": "P0"}, - {"content": "Write failing tests for JWT token generation and validation", "priority": "P0"}, - {"content": "Implement JWT service with access/refresh token support", "priority": "P0"}, - {"content": "Run JWT tests and verify token expiration handling works", "priority": "P0"}, - {"content": "Write integration tests for login flow (valid creds, invalid, locked account)", "priority": "P1"}, - {"content": "Implement login endpoint with rate limiting and audit logging", "priority": "P1"}, - {"content": "Add error handling for network failures and database timeouts", "priority": "P1"}, - {"content": "Run full test suite and fix any failures", "priority": "P1"}, - {"content": "Verify all original requirements are implemented and tested", "priority": "P1"} + {"id": "P0-001", "content": "Create project structure with src/, tests/, and config directories", "priority": "P0", "verificationCriteria": "Directories exist, package.json initialized", "tddPhase": "setup", "dependencies": []}, + {"id": "P0-002", "content": "Define TypeScript interfaces for User, Session, and AuthToken types", "priority": "P0", "verificationCriteria": "Types compile without errors, exported from types.ts", "tddPhase": "setup", "dependencies": ["P0-001"]}, + {"id": "P0-003", "content": "Write failing unit tests for password hashing (valid password, empty, too short)", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-002"]}, + {"id": "P0-004", "content": "Implement password hashing with bcrypt, configurable salt rounds", "priority": "P0", "verificationCriteria": "npm test -- --grep='password' passes", "tddPhase": "impl", "dependencies": ["P0-003"]}, + {"id": "P0-005", "content": "Write failing tests for JWT token generation and validation", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-004"]}, + {"id": "P0-006", "content": "Implement JWT service with access/refresh token support", "priority": "P0", "verificationCriteria": "npm test -- --grep='JWT' passes", "tddPhase": "impl", "dependencies": ["P0-005"]}, + {"id": "P1-001", "content": "Write integration tests for login flow (valid creds, invalid, locked account)", "priority": "P1", "verificationCriteria": "Integration tests exist, fail until endpoint implemented", "tddPhase": "test", "dependencies": ["P0-006"]}, + {"id": "P1-002", "content": "Implement login endpoint with rate limiting and audit logging", "priority": "P1", "verificationCriteria": "All login tests pass, endpoint returns 200/401 correctly", "tddPhase": "impl", "dependencies": ["P1-001"]}, + {"id": "P1-003", "content": "Run full test suite and verify all tests pass", "priority": "P1", "verificationCriteria": "npm test exits with code 0, coverage > 80%", "tddPhase": "verify", "dependencies": ["P1-002"]} ] ## CRITICAL RULES -1. EVERY implementation step should have a corresponding test step BEFORE it -2. Include "Run tests and debug/fix" steps after implementation blocks -3. Be SPECIFIC - not "Add tests" but "Write tests for X covering Y and Z" -4. Think about what could fail and add defensive steps -5. End with verification that ALL original requirements are met -6. Use P0 for foundation and core features, P1 for required work, P2 for nice-to-have +1. EVERY task MUST have verificationCriteria - this is non-negotiable! +2. EVERY implementation step should have a corresponding test step BEFORE it +3. Use tddPhase: "test" for writing tests, "impl" for implementation +4. Dependencies must form a valid DAG - no cycles +5. Be SPECIFIC - not "Add tests" but "Write tests for X covering Y and Z" +6. End with verification that ALL original requirements are met +7. Use P0 for foundation and core features, P1 for required work, P2 for nice-to-have NOW: Generate the implementation plan for the task above. Think step by step.`; @@ -2352,10 +2354,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array'); } - // Validate and normalize items + // Validate and normalize items with enhanced fields items = parsed.map((item: unknown, idx: number) => { if (typeof item !== 'object' || item === null) { - return { content: `Step ${idx + 1}`, priority: null }; + return { + id: `task-${idx}`, + content: `Step ${idx + 1}`, + priority: null, + verificationCriteria: 'Task completed successfully', + status: 'pending' as const, + attempts: 0, + version: 1, + }; } const obj = item as Record; const content = typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`; @@ -2363,7 +2373,26 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') { priority = obj.priority; } - return { content, priority }; + + // Parse tddPhase + let tddPhase: 'setup' | 'test' | 'impl' | 'verify' | undefined; + if (obj.tddPhase === 'setup' || obj.tddPhase === 'test' || obj.tddPhase === 'impl' || obj.tddPhase === 'verify') { + tddPhase = obj.tddPhase; + } + + return { + id: obj.id ? String(obj.id) : `task-${idx}`, + content, + priority, + verificationCriteria: typeof obj.verificationCriteria === 'string' + ? obj.verificationCriteria + : 'Task completed successfully', + tddPhase, + dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : [], + status: 'pending' as const, + attempts: 0, + version: 1, + }; }); // No artificial limit - let Claude generate what's needed @@ -2436,6 +2465,123 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } }); + // ============ Plan Management Endpoints ============ + // These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning + + // Update a specific plan task (status, attempts, errors) + this.app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => { + const { id, taskId } = req.params as { id: string; taskId: string }; + const session = this.sessions.get(id); + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + const update = req.body as { + status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'; + error?: string; + incrementAttempts?: boolean; + }; + + const result = tracker.updatePlanTask(taskId, update); + if (!result.success) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found'); + } + + this.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task }); + return { success: true, data: result.task }; + }); + + // Trigger a checkpoint review (at iterations 5, 10, 20, etc.) + this.app.post('/api/sessions/:id/plan/checkpoint', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + const checkpoint = tracker.generateCheckpointReview(); + this.broadcast('session:planCheckpoint', { sessionId: id, checkpoint }); + return { success: true, data: checkpoint }; + }); + + // Get plan version history + this.app.get('/api/sessions/:id/plan/history', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + return { success: true, data: tracker.getPlanHistory() }; + }); + + // Rollback to a previous plan version + this.app.post('/api/sessions/:id/plan/rollback/:version', async (req) => { + const { id, version } = req.params as { id: string; version: string }; + const session = this.sessions.get(id); + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + const result = tracker.rollbackToVersion(parseInt(version, 10)); + if (!result.success) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found'); + } + + this.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) }); + return { success: true, data: result.plan }; + }); + + // Add a new task to the plan (for runtime adaptation) + this.app.post('/api/sessions/:id/plan/task', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + const task = req.body as { + content: string; + priority?: 'P0' | 'P1' | 'P2'; + verificationCriteria?: string; + dependencies?: string[]; + insertAfter?: string; // Task ID to insert after + }; + + if (!task.content) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task content is required'); + } + + const result = tracker.addPlanTask(task); + this.broadcast('session:planTaskAdded', { sessionId: id, task: result.task }); + return { success: true, data: result.task }; + }); + // ============ App Settings Endpoints ============ const settingsPath = join(homedir(), '.claudeman', 'settings.json'); @@ -4281,7 +4427,16 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.sseHealthCheckTimer = null; } - // Clear all SSE clients + // Gracefully close all SSE connections before clearing + for (const client of this.sseClients) { + try { + // Send a final event to notify clients of shutdown + this.sendSSE(client, 'server:shutdown', { reason: 'Server stopping' }); + client.raw.end(); + } catch { + // Client may already be disconnected + } + } this.sseClients.clear(); // Clear batch timers