From 24e8ebc9eaa58ad65208972e5140bbd0014f6cc1 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 28 Jan 2026 01:25:25 +0100 Subject: [PATCH] chore: bump version to 0.1406 --- CLAUDE.md | 2 +- package.json | 2 +- src/plan-orchestrator.ts | 364 +++++++++++++++++++++++++++++++++------ src/web/public/app.js | 80 +++++++-- src/web/server.ts | 71 +++++--- 5 files changed, 423 insertions(+), 96 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d07a0236..fb8d9a36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1405 (must match `package.json`) +**Version**: 0.1406 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index 58a76aae..767a22f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1405", + "version": "0.1406", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index 7a977332..d6cd871c 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -245,13 +245,77 @@ export interface FinalReviewResult { export type SubagentCallback = (event: PlanSubagentEvent) => void; +// ============================================================================ +// JSON Repair Helper +// ============================================================================ + +/** + * Attempt to parse JSON with automatic repair for common LLM output issues. + * Handles: trailing commas, unescaped newlines in strings, truncated output. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function tryParseJSON(jsonString: string): { success: boolean; data?: any; error?: string } { + // First, try direct parse + try { + return { success: true, data: JSON.parse(jsonString) }; + } catch (firstError) { + // Try repairs + let repaired = jsonString; + + // 1. Remove trailing commas before ] or } + repaired = repaired.replace(/,(\s*[\]}])/g, '$1'); + + // 2. Fix unescaped newlines in strings (common LLM issue) + // Match strings and escape newlines within them + repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => { + return match.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'); + }); + + // 3. Try to close unclosed arrays/objects (truncated output) + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + + // Add missing closing brackets + for (let i = 0; i < openBraces - closeBraces; i++) { + // Find last incomplete object and close it + repaired = repaired.replace(/,\s*$/, '') + '}'; + } + for (let i = 0; i < openBrackets - closeBrackets; i++) { + repaired = repaired.replace(/,\s*$/, '') + ']'; + } + + // Try parsing repaired JSON + try { + return { success: true, data: JSON.parse(repaired) }; + } catch (secondError) { + // 4. Last resort: try to extract valid items from array + // Find all complete objects in the array + const objectMatches = jsonString.match(/\{[^{}]*\}/g); + if (objectMatches && objectMatches.length > 0) { + try { + const items = objectMatches.map(obj => JSON.parse(obj)); + console.warn(`[JSON Repair] Extracted ${items.length} items from malformed array`); + return { success: true, data: items }; + } catch { + // Give up + } + } + + const errMsg = firstError instanceof Error ? firstError.message : String(firstError); + return { success: false, error: errMsg }; + } + } +} + // ============================================================================ // Constants // ============================================================================ -const RESEARCH_TIMEOUT_MS = 360000; // 6 minutes for research (may need web search) -const SUBAGENT_TIMEOUT_MS = 300000; // 5 minutes per subagent (Opus needs time for complex analysis) -const VERIFICATION_TIMEOUT_MS = 480000; // 8 minutes for verification (Opus + large plans) +const RESEARCH_TIMEOUT_MS = 600000; // 10 minutes for research (may need extensive web search) +const SUBAGENT_TIMEOUT_MS = 480000; // 8 minutes per subagent (Opus needs time for complex analysis) +const VERIFICATION_TIMEOUT_MS = 600000; // 10 minutes for verification (Opus + large plans) const MODEL_RESEARCH = 'opus'; // Best model for research (needs reasoning for web search) const MODEL_ANALYSIS = 'opus'; // Best model for thorough analysis const MODEL_VERIFICATION = 'opus'; // Best model for verification @@ -1245,10 +1309,24 @@ Use \`/init\` to load this context, then read specific files as needed. onProgress?.('research', 'Gathering external resources and codebase context...'); - const { result } = await Promise.race([ - session.runPrompt(prompt, { model: MODEL_RESEARCH }), - this.timeout(RESEARCH_TIMEOUT_MS), - ]); + // Periodic progress updates showing elapsed time + const progressInterval = setInterval(() => { + const elapsedSec = Math.floor((Date.now() - startTime) / 1000); + const timeoutSec = Math.floor(RESEARCH_TIMEOUT_MS / 1000); + onProgress?.('research', `Research agent working... (${elapsedSec}s / ${timeoutSec}s timeout)`); + console.log(`[PlanOrchestrator] Research agent: ${elapsedSec}s elapsed (timeout: ${timeoutSec}s)`); + }, 30000); // Update every 30 seconds + + let result: string; + try { + const response = await Promise.race([ + session.runPrompt(prompt, { model: MODEL_RESEARCH }), + this.timeoutWithContext(RESEARCH_TIMEOUT_MS, 'Research agent', startTime), + ]); + result = response.result; + } finally { + clearInterval(progressInterval); + } // Check if cancelled during execution if (this.cancelled) { @@ -1264,7 +1342,7 @@ Use \`/init\` to load this context, then read specific files as needed. return { ...defaultResult, error: 'Cancelled', durationMs: Date.now() - startTime }; } - // Parse JSON from result + // Parse JSON from result with repair for common LLM issues const jsonMatch = result.match(/\{[\s\S]*\}/); if (!jsonMatch) { console.warn('[PlanOrchestrator] Research agent returned no JSON'); @@ -1280,7 +1358,22 @@ Use \`/init\` to load this context, then read specific files as needed. return { ...defaultResult, durationMs: Date.now() - startTime }; } - const parsed = JSON.parse(jsonMatch[0]); + const parseResult = tryParseJSON(jsonMatch[0]); + if (!parseResult.success) { + console.warn('[PlanOrchestrator] Research agent JSON parse failed:', parseResult.error); + onSubagent?.({ + type: 'completed', + agentId, + agentType: 'research', + model: MODEL_RESEARCH, + status: 'completed', + detail: 'Research completed with parse issues', + durationMs: Date.now() - startTime, + }); + return { ...defaultResult, error: parseResult.error, durationMs: Date.now() - startTime }; + } + + const parsed = parseResult.data; // Parse external resources const externalResources = (Array.isArray(parsed.externalResources) ? parsed.externalResources : []).map((r: Record) => ({ @@ -1476,30 +1569,64 @@ Use \`/init\` to load this context, then read specific files as needed. onProgress?.('review-injection', `Added ${reviewsAdded} auto-review task(s)`); } - // Phase 5: Execution Optimization for Claude Code - onProgress?.('execution-optimization', 'Running execution optimizer for Claude Code...'); - const executionResult = await this.runExecutionOptimizer( - taskDescription, - planWithReviews, - onProgress, - onSubagent - ); - - totalCost += 0.008; // Execution optimizer cost estimate + // Phase 5: Execution Optimization for Claude Code (optional - failures don't block plan) + let executionResult: Awaited>; + try { + onProgress?.('execution-optimization', 'Running execution optimizer for Claude Code...'); + executionResult = await this.runExecutionOptimizer( + taskDescription, + planWithReviews, + onProgress, + onSubagent + ); + totalCost += 0.008; // Execution optimizer cost estimate + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + console.warn('[PlanOrchestrator] Execution optimizer failed (continuing without):', errMsg); + onProgress?.('execution-optimization', `Skipped (${errMsg.slice(0, 50)})`); + executionResult = { + success: false, + optimizedPlan: planWithReviews, + parallelGroups: [], + executionStrategy: { + totalParallelGroups: 0, + sequentialBlockers: [], + freshContextPoints: [], + estimatedTotalTokens: planWithReviews.length * 20000, + estimatedAgentSpawns: Math.ceil(planWithReviews.length / 3), + criticalPath: [], + optimizationNotes: [`Skipped due to error: ${errMsg}`], + }, + }; + } // Use optimized plan if successful, otherwise fall back to review plan const optimizedPlan = executionResult.success ? executionResult.optimizedPlan : planWithReviews; - // Phase 6: Final Review - Holistic validation - onProgress?.('final-review', 'Running final review for holistic validation...'); - const finalReviewResult = await this.runFinalReview( - taskDescription, - optimizedPlan, - onProgress, - onSubagent - ); - - totalCost += 0.01; // Final review cost estimate + // Phase 6: Final Review - Holistic validation (optional - failures don't block plan) + let finalReviewResult: FinalReviewResult; + try { + onProgress?.('final-review', 'Running final review for holistic validation...'); + finalReviewResult = await this.runFinalReview( + taskDescription, + optimizedPlan, + onProgress, + onSubagent + ); + totalCost += 0.01; // Final review cost estimate + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + console.warn('[PlanOrchestrator] Final review failed (continuing without):', errMsg); + onProgress?.('final-review', `Skipped (${errMsg.slice(0, 50)})`); + finalReviewResult = { + overallAssessment: 'ready', + scores: { logic: 0.8, completeness: 0.8, coherence: 0.8, feasibility: 0.8, overall: 0.8 }, + summary: `Review skipped: ${errMsg}`, + issues: [], + missingTasks: [], + recommendations: [], + }; + } // Apply any missing tasks from final review let finalPlan = optimizedPlan; @@ -1620,10 +1747,31 @@ Use \`/init\` to load this context, then read specific files as needed. try { onProgress?.('subagent', `Running ${agentType} analysis...`); - const { result } = await Promise.race([ - session.runPrompt(prompt, { model: MODEL_ANALYSIS }), - this.timeout(SUBAGENT_TIMEOUT_MS), - ]); + // Periodic progress updates showing elapsed time + const progressInterval = setInterval(() => { + const elapsedSec = Math.floor((Date.now() - startTime) / 1000); + const timeoutSec = Math.floor(SUBAGENT_TIMEOUT_MS / 1000); + console.log(`[PlanOrchestrator] ${agentType} agent: ${elapsedSec}s elapsed (timeout: ${timeoutSec}s)`); + onSubagent?.({ + type: 'progress', + agentId, + agentType, + model: MODEL_ANALYSIS, + status: 'running', + detail: `${agentType} analysis... (${elapsedSec}s / ${timeoutSec}s)`, + }); + }, 30000); // Update every 30 seconds + + let result: string; + try { + const response = await Promise.race([ + session.runPrompt(prompt, { model: MODEL_ANALYSIS }), + this.timeoutWithContext(SUBAGENT_TIMEOUT_MS, `${agentType} agent`, startTime), + ]); + result = response.result; + } finally { + clearInterval(progressInterval); + } // Check if cancelled during execution if (this.cancelled) { @@ -1645,7 +1793,7 @@ Use \`/init\` to load this context, then read specific files as needed. }; } - // Parse JSON from result + // Parse JSON from result with repair for common LLM issues const jsonMatch = result.match(/\[[\s\S]*\]/); if (!jsonMatch) { onSubagent?.({ @@ -1666,7 +1814,28 @@ Use \`/init\` to load this context, then read specific files as needed. }; } - const parsed = JSON.parse(jsonMatch[0]); + const parseResult = tryParseJSON(jsonMatch[0]); + if (!parseResult.success) { + console.error(`[PlanOrchestrator] ${agentType} JSON parse failed:`, parseResult.error); + onSubagent?.({ + type: 'failed', + agentId, + agentType, + model: MODEL_ANALYSIS, + status: 'failed', + error: `JSON parse error: ${parseResult.error}`, + durationMs: Date.now() - startTime, + }); + return { + agentType, + items: [], + success: false, + error: `JSON parse error: ${parseResult.error}`, + durationMs: Date.now() - startTime, + }; + } + + const parsed = parseResult.data; if (!Array.isArray(parsed)) { onSubagent?.({ type: 'failed', @@ -1930,24 +2099,51 @@ Use \`/init\` to load this context, then read specific files as needed. onProgress?.('verification', 'Validating plan quality...'); - const { result } = await Promise.race([ - session.runPrompt(prompt, { model: MODEL_VERIFICATION }), - this.timeout(VERIFICATION_TIMEOUT_MS), - ]); + // Periodic progress updates showing elapsed time + const progressInterval = setInterval(() => { + const elapsedSec = Math.floor((Date.now() - startTime) / 1000); + const timeoutSec = Math.floor(VERIFICATION_TIMEOUT_MS / 1000); + console.log(`[PlanOrchestrator] verification agent: ${elapsedSec}s elapsed (timeout: ${timeoutSec}s)`); + onSubagent?.({ + type: 'progress', + agentId, + agentType: 'verification', + model: MODEL_VERIFICATION, + status: 'running', + detail: `Verifying plan... (${elapsedSec}s / ${timeoutSec}s)`, + }); + }, 30000); // Update every 30 seconds + + let result: string; + try { + const response = await Promise.race([ + session.runPrompt(prompt, { model: MODEL_VERIFICATION }), + this.timeoutWithContext(VERIFICATION_TIMEOUT_MS, 'verification agent', startTime), + ]); + result = response.result; + } finally { + clearInterval(progressInterval); + } // Check if cancelled during execution if (this.cancelled) { return this.fallbackVerification(synthesizedItems); } - // Parse JSON from result + // Parse JSON from result with repair for common LLM issues const jsonMatch = result.match(/\{[\s\S]*\}/); if (!jsonMatch) { // Fallback: return items with default priorities return this.fallbackVerification(synthesizedItems); } - const parsed = JSON.parse(jsonMatch[0]); + const parseResult = tryParseJSON(jsonMatch[0]); + if (!parseResult.success) { + console.warn('[PlanOrchestrator] Verification JSON parse failed:', parseResult.error); + return this.fallbackVerification(synthesizedItems); + } + + const parsed = parseResult.data; const validatedPlan: PlanItem[] = (parsed.validatedPlan || []).map((item: unknown, idx: number) => { if (typeof item !== 'object' || item === null) { @@ -2169,10 +2365,31 @@ Use \`/init\` to load this context, then read specific files as needed. onProgress?.('execution-optimization', 'Analyzing parallelization opportunities...'); - const { result } = await Promise.race([ - session.runPrompt(prompt, { model: MODEL_VERIFICATION }), - this.timeout(VERIFICATION_TIMEOUT_MS), - ]); + // Periodic progress updates showing elapsed time + const progressInterval = setInterval(() => { + const elapsedSec = Math.floor((Date.now() - startTime) / 1000); + const timeoutSec = Math.floor(VERIFICATION_TIMEOUT_MS / 1000); + console.log(`[PlanOrchestrator] execution optimizer: ${elapsedSec}s elapsed (timeout: ${timeoutSec}s)`); + onSubagent?.({ + type: 'progress', + agentId, + agentType: 'execution', + model: MODEL_VERIFICATION, + status: 'running', + detail: `Optimizing execution... (${elapsedSec}s / ${timeoutSec}s)`, + }); + }, 30000); // Update every 30 seconds + + let result: string; + try { + const response = await Promise.race([ + session.runPrompt(prompt, { model: MODEL_VERIFICATION }), + this.timeoutWithContext(VERIFICATION_TIMEOUT_MS, 'execution optimizer', startTime), + ]); + result = response.result; + } finally { + clearInterval(progressInterval); + } // Check if cancelled during execution if (this.cancelled) { @@ -2196,7 +2413,13 @@ Use \`/init\` to load this context, then read specific files as needed. return defaultResult; } - const parsed = JSON.parse(jsonMatch[0]); + const parseResult = tryParseJSON(jsonMatch[0]); + if (!parseResult.success) { + console.warn('[PlanOrchestrator] Execution optimizer JSON parse failed:', parseResult.error); + return defaultResult; + } + + const parsed = parseResult.data; // Parse optimized plan items const optimizedPlan: PlanItem[] = (parsed.optimizedPlan || []).map((item: Record, idx: number) => { @@ -2370,17 +2593,38 @@ Use \`/init\` to load this context, then read specific files as needed. onProgress?.('final-review', 'Analyzing overall plan coherence...'); - const { result } = await Promise.race([ - session.runPrompt(prompt, { model: MODEL_VERIFICATION }), - this.timeout(VERIFICATION_TIMEOUT_MS), - ]); + // Periodic progress updates showing elapsed time + const progressInterval = setInterval(() => { + const elapsedSec = Math.floor((Date.now() - startTime) / 1000); + const timeoutSec = Math.floor(VERIFICATION_TIMEOUT_MS / 1000); + console.log(`[PlanOrchestrator] final review: ${elapsedSec}s elapsed (timeout: ${timeoutSec}s)`); + onSubagent?.({ + type: 'progress', + agentId, + agentType: 'final-review', + model: MODEL_VERIFICATION, + status: 'running', + detail: `Final review... (${elapsedSec}s / ${timeoutSec}s)`, + }); + }, 30000); // Update every 30 seconds + + let result: string; + try { + const response = await Promise.race([ + session.runPrompt(prompt, { model: MODEL_VERIFICATION }), + this.timeoutWithContext(VERIFICATION_TIMEOUT_MS, 'final review', startTime), + ]); + result = response.result; + } finally { + clearInterval(progressInterval); + } // Check if cancelled during execution if (this.cancelled) { return defaultResult; } - // Parse JSON from result + // Parse JSON from result with repair for common LLM issues const jsonMatch = result.match(/\{[\s\S]*\}/); if (!jsonMatch) { console.warn('[PlanOrchestrator] Final review returned no JSON, using defaults'); @@ -2397,7 +2641,13 @@ Use \`/init\` to load this context, then read specific files as needed. return defaultResult; } - const parsed = JSON.parse(jsonMatch[0]); + const parseResult = tryParseJSON(jsonMatch[0]); + if (!parseResult.success) { + console.warn('[PlanOrchestrator] Final review JSON parse failed:', parseResult.error); + return defaultResult; + } + + const parsed = parseResult.data; // Parse scores const scores = { @@ -2526,11 +2776,15 @@ Use \`/init\` to load this context, then read specific files as needed. } /** - * Create a timeout promise. + * Create a timeout promise with context about what timed out. */ - private timeout(ms: number): Promise { + private timeoutWithContext(ms: number, context: string, startTime: number): Promise { return new Promise((_, reject) => { - setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms); + setTimeout(() => { + const elapsedSec = Math.floor((Date.now() - startTime) / 1000); + const timeoutSec = Math.floor(ms / 1000); + reject(new Error(`${context} timed out after ${elapsedSec}s (limit: ${timeoutSec}s)`)); + }, ms); }); } diff --git a/src/web/public/app.js b/src/web/public/app.js index eba7be67..64798750 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1946,6 +1946,31 @@ class ClaudemanApp { hintEl.textContent = data.detail; } }); + + // Plan generation started - store orchestrator ID for cancellation + addListener('plan:started', (e) => { + const data = JSON.parse(e.data); + console.log('[Plan Started]', data); + this.activePlanOrchestratorId = data.orchestratorId; + }); + + // Plan generation cancelled + addListener('plan:cancelled', (e) => { + const data = JSON.parse(e.data); + console.log('[Plan Cancelled]', data); + if (this.activePlanOrchestratorId === data.orchestratorId) { + this.activePlanOrchestratorId = null; + } + }); + + // Plan generation completed + addListener('plan:completed', (e) => { + const data = JSON.parse(e.data); + console.log('[Plan Completed]', data); + if (this.activePlanOrchestratorId === data.orchestratorId) { + this.activePlanOrchestratorId = null; + } + }); } setConnectionStatus(status) { @@ -2985,18 +3010,23 @@ class ClaudemanApp { ]; const detailedPhases = [ - { time: 0, title: 'Spawning analysis subagents...', hint: 'Starting 4 specialist agents in parallel' }, - { time: 3, title: 'Requirements Analyst working...', hint: 'Extracting explicit and implicit requirements' }, - { time: 6, title: 'Architecture Planner working...', hint: 'Designing modules and interfaces' }, - { time: 9, title: 'TDD Specialist working...', hint: 'Planning test-first implementation' }, - { time: 12, title: 'Risk Analyst working...', hint: 'Identifying edge cases and blockers' }, - { time: 20, title: 'Subagents completing...', hint: 'Collecting analysis results' }, - { time: 30, title: 'Synthesizing results...', hint: 'Merging and deduplicating items' }, - { time: 40, title: 'Starting verification...', hint: 'Running quality assurance subagent' }, - { time: 50, title: 'Assigning priorities...', hint: 'Determining P0/P1/P2 for each task' }, - { time: 60, title: 'Checking for gaps...', hint: 'Identifying missing requirements' }, - { time: 75, title: 'Finalizing plan...', hint: 'Preparing validated task list' }, - { time: 90, title: 'Still working...', hint: 'Complex tasks take longer - hang tight!' }, + // Phase 0: Research (can take up to 10 minutes for complex tasks) + { time: 0, title: 'Starting research agent...', hint: 'Gathering external resources and codebase context' }, + { time: 30, title: 'Research agent working...', hint: 'Searching docs, GitHub repos, and analyzing codebase' }, + { time: 60, title: 'Research continuing...', hint: 'Web search and codebase exploration in progress' }, + { time: 120, title: 'Research agent deep diving...', hint: 'Complex tasks require thorough research' }, + { time: 180, title: 'Research almost complete...', hint: 'Compiling findings and recommendations' }, + // Phase 1: Parallel Analysis (after research completes) + // Note: These times are fallbacks - real-time SSE updates override them + { time: 300, title: 'Spawning analysis subagents...', hint: 'Starting 4 specialist agents in parallel' }, + { time: 330, title: 'Subagents analyzing...', hint: 'Requirements, Architecture, Testing, Risk analysts working' }, + { time: 400, title: 'Subagents completing...', hint: 'Collecting analysis results' }, + // Phase 2+: Synthesis and verification + { time: 450, title: 'Synthesizing results...', hint: 'Merging and deduplicating items' }, + { time: 500, title: 'Running verification...', hint: 'Quality assurance and priority assignment' }, + { time: 550, title: 'Optimizing execution...', hint: 'Planning parallelization for Claude Code' }, + { time: 600, title: 'Final review...', hint: 'Holistic validation and gap detection' }, + { time: 660, title: 'Still working...', hint: 'Complex tasks take longer - hang tight!' }, ]; const phases = isDetailed ? detailedPhases : standardPhases; @@ -3034,10 +3064,14 @@ class ClaudemanApp { const hintEl = document.getElementById('planLoadingHint'); if (titleEl && event.data.phase) { const phaseLabels = { - 'parallel-analysis': 'Running parallel analysis...', + 'research': 'Research agent working...', + 'parallel-analysis': 'Spawning analysis subagents...', 'subagent': event.data.detail || 'Subagent working...', 'synthesis': 'Synthesizing results...', 'verification': 'Running verification...', + 'review-injection': 'Adding review tasks...', + 'execution-optimization': 'Optimizing for Claude Code...', + 'final-review': 'Running final review...', }; titleEl.textContent = phaseLabels[event.data.phase] || event.data.phase; } @@ -3595,9 +3629,25 @@ class ClaudemanApp { }, 50); } - cancelPlanGeneration() { + async cancelPlanGeneration() { this.stopPlanGeneration(); this.planGenerationStopped = true; // Ignore future SSE events + + // Call the cancel API to stop server-side processing + if (this.activePlanOrchestratorId) { + try { + console.log('[Cancel] Sending cancel request for', this.activePlanOrchestratorId); + await fetch('/api/cancel-plan-generation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orchestratorId: this.activePlanOrchestratorId }), + }); + this.activePlanOrchestratorId = null; + } catch (err) { + console.error('[Cancel] Failed to cancel plan generation:', err); + } + } + this.showToast('Plan generation stopped', 'info'); // Allow user to proceed without a plan by clicking Next @@ -3783,6 +3833,7 @@ class ClaudemanApp { win.style.zIndex = ++this.planSubagentWindowZIndex; const typeLabels = { + research: 'Research Agent', requirements: 'Requirements Analyst', architecture: 'Architecture Planner', testing: 'TDD Specialist', @@ -3793,6 +3844,7 @@ class ClaudemanApp { }; const typeIcons = { + research: '๐Ÿ”ฌ', requirements: '๐Ÿ“‹', architecture: '๐Ÿ—๏ธ', testing: '๐Ÿงช', diff --git a/src/web/server.ts b/src/web/server.ts index 0a1b1fe7..a1b0a93b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -307,6 +307,8 @@ export class WebServer extends EventEmitter { private readonly serverStartTime: number = Date.now(); // Pending respawn start timers (for cleanup on shutdown) private pendingRespawnStarts: Map = new Map(); + // Active plan orchestrators (for cancellation via API) + private activePlanOrchestrators: Map = new Map(); // Grace period before starting restored respawn controllers (2 minutes) private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000; @@ -2445,21 +2447,13 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const orchestrator = new PlanOrchestrator(this.screenManager, process.cwd(), outputDir); - // Cancel orchestrator if client disconnects (user clicked Stop) - // Note: We use the socket's 'close' event, not req.raw.on('close') which fires - // when the request body is done being read (immediately after parsing). - let clientDisconnected = false; - let responseSent = false; - const socket = req.raw.socket; - const onClose = () => { - // Only cancel if response hasn't been sent yet (true disconnection) - if (!responseSent) { - clientDisconnected = true; - console.log('[API] Client disconnected during plan generation, cancelling...'); - orchestrator.cancel().catch(() => { /* ignore */ }); - } - }; - socket.on('close', onClose); + // Store orchestrator for potential cancellation via API (not on disconnect) + // Plan generation continues even if browser disconnects - only explicit cancel stops it + const orchestratorId = `plan-${Date.now()}`; + this.activePlanOrchestrators.set(orchestratorId, orchestrator); + + // Broadcast the orchestrator ID so frontend can cancel if needed + this.broadcast('plan:started', { orchestratorId }); // Track progress for SSE updates const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = []; @@ -2492,14 +2486,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; onSubagent ); - // Mark response as being sent and clean up listener - responseSent = true; - socket.off('close', onClose); - - // If client disconnected, don't bother returning response - if (clientDisconnected) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Cancelled by client'); - } + // Clean up orchestrator from active map + this.activePlanOrchestrators.delete(orchestratorId); + this.broadcast('plan:completed', { orchestratorId, success: result.success }); if (!result.success) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed'); @@ -2512,15 +2501,47 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; costUsd: result.costUsd, metadata: result.metadata, progressLog: progressUpdates, + orchestratorId, }, }; } catch (err) { - responseSent = true; - socket.off('close', onClose); + // Clean up on error too + this.activePlanOrchestrators.delete(orchestratorId); + this.broadcast('plan:completed', { orchestratorId, success: false, error: getErrorMessage(err) }); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Detailed plan generation failed: ' + getErrorMessage(err)); } }); + // Cancel active plan generation + this.app.post('/api/cancel-plan-generation', async (req): Promise => { + const { orchestratorId } = req.body as { orchestratorId?: string }; + + // If specific orchestrator ID provided, cancel just that one + if (orchestratorId) { + const orchestrator = this.activePlanOrchestrators.get(orchestratorId); + if (!orchestrator) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Plan generation not found or already completed'); + } + console.log(`[API] Cancelling plan generation ${orchestratorId}`); + await orchestrator.cancel(); + this.activePlanOrchestrators.delete(orchestratorId); + this.broadcast('plan:cancelled', { orchestratorId }); + return { success: true, data: { cancelled: orchestratorId } }; + } + + // Otherwise cancel all active plan generations + const cancelled: string[] = []; + for (const [id, orchestrator] of this.activePlanOrchestrators) { + console.log(`[API] Cancelling plan generation ${id}`); + await orchestrator.cancel(); + cancelled.push(id); + this.broadcast('plan:cancelled', { orchestratorId: id }); + } + this.activePlanOrchestrators.clear(); + + return { success: true, data: { cancelled } }; + }); + // ============ Plan Management Endpoints ============ // These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning