diff --git a/public/claude-ide/bug-tracker.js b/public/claude-ide/bug-tracker.js index 60dfd95c..be345d81 100644 --- a/public/claude-ide/bug-tracker.js +++ b/public/claude-ide/bug-tracker.js @@ -14,6 +14,12 @@ activityLog: [], // New: stores AI activity stream addError(error) { + // Skip 'info' type errors - they're for learning, not bugs + if (error.type === 'info') { + console.log('[BugTracker] Skipping info-type error:', error.message); + return null; + } + const errorId = this.generateErrorId(error); const existingError = this.errors.find(e => e.id === errorId); @@ -221,17 +227,43 @@ triggerManualFix(errorId) { const error = this.errors.find(e => e.id === errorId); - if (error) { - // Report to server to trigger fix - fetch('/claude/api/log-error', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...error, - manualTrigger: true - }) - }); + if (!error) { + console.error('[BugTracker] Error not found:', errorId); + return; } + + // Report to server to trigger fix + fetch('/claude/api/log-error', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...error, + manualTrigger: true + }) + }) + .then(res => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + return res.json(); + }) + .then(data => { + console.log('[BugTracker] Manual fix triggered:', data); + + // Update error status to 'fixing' + this.startFix(errorId); + + // Show success feedback + if (typeof showToast === 'function') { + showToast('Auto-fix agent triggered', 'success'); + } + }) + .catch(err => { + console.error('[BugTracker] Failed to trigger fix:', err); + if (typeof showToast === 'function') { + showToast('Failed to trigger auto-fix', 'error'); + } + }); }, getTimeAgo(timestamp) { diff --git a/public/claude-ide/components/approval-card.css b/public/claude-ide/components/approval-card.css new file mode 100644 index 00000000..14c7d360 --- /dev/null +++ b/public/claude-ide/components/approval-card.css @@ -0,0 +1,265 @@ +/* ============================================================ + Approval Card Component Styles + ============================================================ */ + +.approval-card { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border: 1px solid #4a9eff; + border-radius: 12px; + padding: 16px; + margin: 12px 0; + box-shadow: 0 4px 20px rgba(74, 158, 255, 0.2); + animation: approvalCardSlideIn 0.3s ease-out; +} + +@keyframes approvalCardSlideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Header Section */ +.approval-card-header { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(74, 158, 255, 0.3); + margin-bottom: 12px; +} + +.approval-icon { + font-size: 20px; + animation: approvalIconPulse 2s infinite; +} + +@keyframes approvalIconPulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +.approval-label { + font-weight: 600; + color: #4a9eff; + font-size: 13px; +} + +.approval-command { + background: rgba(0, 0, 0, 0.3); + padding: 4px 8px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + color: #7dd3fc; + flex: 1; + overflow-x: auto; +} + +/* Explanation Section */ +.approval-explanation { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px; + background: rgba(74, 158, 255, 0.1); + border-radius: 8px; + margin-bottom: 12px; + font-size: 13px; + line-height: 1.4; +} + +.explanation-icon { + font-size: 14px; + flex-shrink: 0; +} + +.explanation-text { + color: #e0e0e0; + flex: 1; +} + +/* Buttons Section */ +.approval-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.approval-buttons button { + flex: 1; + min-width: 100px; + padding: 10px 16px; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-approve { + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: white; +} + +.btn-approve:hover { + background: linear-gradient(135deg, #16a34a 0%, #15803d 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3); +} + +.btn-custom { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; +} + +.btn-custom:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.btn-reject { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; +} + +.btn-reject:hover { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +/* Custom Instructions Section */ +.approval-custom { + margin-top: 12px; + padding: 12px; + background: rgba(59, 130, 246, 0.1); + border-radius: 8px; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.custom-label { + display: block; + font-size: 12px; + color: #94a3b8; + margin-bottom: 6px; +} + +.custom-input { + width: 100%; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(74, 158, 255, 0.3); + border-radius: 6px; + color: #e0e0e0; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 13px; + outline: none; + transition: all 0.2s ease; +} + +.custom-input:focus { + border-color: #4a9eff; + box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.1); +} + +.custom-input::placeholder { + color: #64748b; +} + +.custom-buttons { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.btn-approve-small, +.btn-cancel-small { + flex: 1; + padding: 8px 12px; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-approve-small { + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: white; +} + +.btn-approve-small:hover { + background: linear-gradient(135deg, #16a34a 0%, #15803d 100%); +} + +.btn-cancel-small { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; +} + +.btn-cancel-small:hover { + background: rgba(100, 116, 139, 0.3); +} + +/* Responsive Design */ +@media (max-width: 600px) { + .approval-buttons { + flex-direction: column; + } + + .approval-buttons button { + width: 100%; + } +} + +/* Loading/Disabled States */ +.approval-card.loading { + opacity: 0.6; + pointer-events: none; +} + +.approval-card.loading .approval-icon { + animation: approvalIconSpin 1s infinite linear; +} + +@keyframes approvalIconSpin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Expired State */ +.approval-card.expired { + opacity: 0.5; + border-color: #ff6b6b; +} + +/* Success State */ +.approval-card.success { + border-color: #22c55e; + animation: approvalCardSuccess 0.5s ease-out; +} + +@keyframes approvalCardSuccess { + 0% { + background: rgba(34, 197, 94, 0.1); + } + 100% { + background: transparent; + } +} diff --git a/public/claude-ide/components/approval-card.js b/public/claude-ide/components/approval-card.js new file mode 100644 index 00000000..a2060b61 --- /dev/null +++ b/public/claude-ide/components/approval-card.js @@ -0,0 +1,267 @@ +/** + * Approval Card Component + * Interactive UI for approving/rejecting commands + */ + +(function() { + 'use strict'; + + // Approval card instance tracking + let activeCards = new Map(); + + /** + * Render approval card + * @param {Object} approvalData - Approval request data + * @returns {HTMLElement} - The approval card element + */ + function renderApprovalCard(approvalData) { + // Check if card already exists + if (activeCards.has(approvalData.id)) { + const existingCard = activeCards.get(approvalData.id); + if (existingCard && existingCard.isConnected) { + return existingCard; + } + } + + const cardId = `approval-card-${approvalData.id}`; + + // Create card container + const card = document.createElement('div'); + card.className = 'approval-card'; + card.id = cardId; + card.dataset.approvalId = approvalData.id; + + // Generate HTML + card.innerHTML = ` +
+ 🤖 + Executing: + ${escapeHtml(approvalData.command)} +
+ ${approvalData.explanation ? ` +
+ â„šī¸ + ${escapeHtml(approvalData.explanation)} +
+ ` : ''} +
+ + + +
+ + `; + + // Store in active cards + activeCards.set(approvalData.id, card); + + return card; + } + + /** + * Handle approve button click + * @param {string} approvalId - Approval ID + */ + function handleApprove(approvalId) { + sendApprovalResponse(approvalId, true, null); + } + + /** + * Handle reject button click + * @param {string} approvalId - Approval ID + */ + function handleReject(approvalId) { + sendApprovalResponse(approvalId, false, null); + } + + /** + * Handle custom instructions click + * @param {string} approvalId - Approval ID + */ + function handleCustom(approvalId) { + const card = activeCards.get(approvalId); + if (!card) return; + + const customSection = card.querySelector('.approval-custom'); + const customButton = card.querySelector('.btn-custom'); + + if (customSection.style.display === 'none') { + // Show custom input + customSection.style.display = 'block'; + const input = card.querySelector(`#${approvalId}-custom-input`); + if (input) { + input.focus(); + } + if (customButton) { + customButton.textContent = 'Close'; + customButton.onclick = () => ApprovalCard.closeCustom(approvalId); + } + } else { + // Close custom input + closeCustom(approvalId); + } + } + + /** + * Execute custom command + * @param {string} approvalId - Approval ID + */ + function executeCustom(approvalId) { + const card = activeCards.get(approvalId); + if (!card) return; + + const input = card.querySelector(`#${approvalId}-custom-input`); + const customCommand = input ? input.value.trim() : ''; + + if (!customCommand) { + // Show error + const existingError = card.querySelector('.approval-custom-error'); + if (!existingError) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'approval-custom-error'; + errorDiv.textContent = 'Please enter a command'; + errorDiv.style.color = '#ff6b6b'; + errorDiv.style.marginTop = '5px'; + errorDiv.style.fontSize = '12px'; + card.querySelector('.approval-custom-buttons').insertBefore(errorDiv, card.querySelector('.approval-custom-buttons').firstChild); + } + input.focus(); + return; + } + + sendApprovalResponse(approvalId, true, customCommand); + } + + /** + * Close custom input + * @param {string} approvalId - Approval ID + */ + function closeCustom(approvalId) { + const card = activeCards.get(approvalId); + if (!card) return; + + const customSection = card.querySelector('.approval-custom'); + const customButton = card.querySelector('.btn-custom'); + + customSection.style.display = 'none'; + customButton.textContent = 'Custom Instructions'; + customButton.onclick = () => ApprovalCard.handleCustom(approvalId); + } + + /** + * Send approval response to server + * @param {string} approvalId - Approval ID + * @param {boolean} approved - Whether user approved + * @param {string|null} customCommand - Custom command if provided + */ + function sendApprovalResponse(approvalId, approved, customCommand) { + // Remove card from UI + const card = activeCards.get(approvalId); + if (card && card.isConnected) { + card.remove(); + } + activeCards.delete(approvalId); + + // Check if this is a server-initiated approval or AI-conversational approval + const pendingApproval = window._pendingApprovals && window._pendingApprovals[approvalId]; + + if (pendingApproval) { + // AI-conversational approval - send as chat message + let responseMessage; + + if (approved) { + if (customCommand) { + responseMessage = `Execute: ${customCommand}`; + } else { + responseMessage = 'yes'; + } + } else { + responseMessage = 'no'; + } + + // Send as chat message + if (typeof sendChatMessage === 'function') { + sendChatMessage(responseMessage, 'webcontainer'); + } else if (window.sendMessageToSession) { + window.sendMessageToSession(responseMessage); + } + + // Clean up pending approval + delete window._pendingApprovals[approvalId]; + } else { + // Server-initiated approval - send via WebSocket + if (window.ws && window.ws.readyState === WebSocket.OPEN) { + window.ws.send(JSON.stringify({ + type: 'approval-response', + id: approvalId, + approved: approved, + customCommand: customCommand, + sessionId: window.attachedSessionId || window.chatSessionId + })); + } else { + console.error('[ApprovalCard] WebSocket not connected'); + } + } + } + + /** + * Handle approval expired event + * @param {string} approvalId - Approval ID + */ + function handleExpired(approvalId) { + const card = activeCards.get(approvalId); + if (card && card.isConnected) { + const header = card.querySelector('.approval-card-header'); + if (header) { + header.innerHTML = ` + âąī¸ + Expired: + This approval request has expired + `; + } + + const buttons = card.querySelector('.approval-buttons'); + if (buttons) { + buttons.style.display = 'none'; + } + + const custom = card.querySelector('.approval-custom'); + if (custom) { + custom.style.display = 'none'; + } + } + } + + /** + * Escape HTML to prevent XSS + * @param {string} text - Text to escape + * @returns {string} - Escaped text + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Export public API + window.ApprovalCard = { + render: renderApprovalCard, + handleApprove, + handleReject, + handleCustom, + executeCustom, + closeCustom, + sendApprovalResponse, + handleExpired + }; + + console.log('[ApprovalCard] Component loaded'); +})(); diff --git a/public/claude-ide/ide.js b/public/claude-ide/ide.js index 80b3a5ea..d9c63fab 100644 --- a/public/claude-ide/ide.js +++ b/public/claude-ide/ide.js @@ -291,6 +291,15 @@ function handleWebSocketMessage(data) { case 'operation-progress': handleOperationProgress(data); break; + case 'approval-request': + handleApprovalRequest(data); + break; + case 'approval-confirmed': + handleApprovalConfirmed(data); + break; + case 'approval-expired': + handleApprovalExpired(data); + break; case 'error': console.error('WebSocket error:', data.error); // Show error in chat if attached @@ -390,6 +399,141 @@ function handleOperationProgress(data) { } } +/** + * Handle approval request event + */ +function handleApprovalRequest(data) { + console.log('Approval request received:', data); + + // Only handle if we're attached to this session + if (data.sessionId !== attachedSessionId) return; + + // Render approval card in chat + if (typeof ApprovalCard !== 'undefined' && ApprovalCard.render) { + const card = ApprovalCard.render(data); + + // Add to chat messages container + const messagesContainer = document.getElementById('chat-messages'); + if (messagesContainer) { + messagesContainer.appendChild(card); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + } else { + console.error('[ApprovalCard] Component not loaded'); + } +} + +/** + * Handle approval confirmed event + */ +function handleApprovalConfirmed(data) { + console.log('Approval confirmed:', data); + + // Only handle if we're attached to this session + if (data.sessionId !== attachedSessionId) return; + + // Show confirmation message + const message = data.approved + ? (data.customCommand + ? `✅ Approved with custom command: ${data.customCommand}` + : '✅ Command approved') + : '❌ Command rejected'; + + if (typeof appendSystemMessage === 'function') { + appendSystemMessage(message); + } +} + +/** + * Handle approval expired event + */ +function handleApprovalExpired(data) { + console.log('Approval expired:', data); + + // Update the approval card to show expired state + if (typeof ApprovalCard !== 'undefined' && ApprovalCard.handleExpired) { + ApprovalCard.handleExpired(data.id); + } +} + +/** + * Detect approval request in AI response + * @param {string} content - AI response content + * @returns {Object|null} - Approval request data or null + */ +function detectApprovalRequest(content) { + if (!content || typeof content !== 'string') return null; + + const lower = content.toLowerCase(); + + // Common approval request patterns + const approvalPatterns = [ + // "Would you like me to proceed with X" + /would\s+(?:you\s+)?like\s+me\s+to\s+proceed\s+(?:with\s+)?(?:executing\s+)?(?:running\s+)?["']?([a-z0-9._\s\-\/]+)["']?/i, + /should\s+i\s+(?:run|execute)\s+["']?([a-z0-9._\s\-\/]+)["']?/i, + /do\s+(?:you\s+)?want\s+me\s+to\s+(?:run|execute)\s+["']?([a-z0-9._\s\-\/]+)["']?/i, + /shall\s+i\s+(?:run|execute)\s+["']?([a-z0-9._\s\-\/]+)["']?/i, + // "Proceed with X?" + /proceed\s+(?:with\s+)?["']?([a-z0-9._\s\-\/]+)["']?\?/i, + // "Execute X?" + /execute\s+["']?([a-z0-9._\s\-\/]+)["']?\?/i, + // "Run X?" + /run\s+["']?([a-z0-9._\s\-\/]+)["']?\?/i + ]; + + for (const pattern of approvalPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + let command = match[1].trim(); + + // Clean up the command + command = command.replace(/[?\s\.]+$/, '').trim(); + + // Extract explanation from the content + let explanation = ''; + const explanationMatch = content.match(/(?:this\s+(?:will|is)\s+(.+?)(?:\.|\n|$))|(?:network\s+operation|file\s+operation|system\s+operation)\s*[:\-]\s*(.+?)(?:\.|\n|$))/i); + if (explanationMatch) { + explanation = (explanationMatch[1] || explanationMatch[2] || '').trim(); + } + + // Generate a reasonable explanation if not found + if (!explanation) { + if (command.includes('ping')) { + explanation = 'Network operation - will send ICMP packets to the target host'; + } else if (command.includes('rm') || command.includes('delete')) { + explanation = 'File deletion operation - files will be permanently removed'; + } else if (command.includes('curl') || command.includes('wget') || command.includes('http')) { + explanation = 'Network operation - will make HTTP request(s)'; + } else if (command.includes('ssh') || command.includes('scp')) { + explanation = 'Remote connection operation - will connect to external host'; + } else { + explanation = `Execute command: ${command}`; + } + } + + return { + command, + explanation, + originalText: content + }; + } + } + + return null; +} + +/** + * Escape HTML to prevent XSS + * @param {string} text - Text to escape + * @returns {string} - Escaped text + */ +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + // Streaming message state for accumulating response chunks let streamingMessageElement = null; let streamingMessageContent = ''; @@ -410,6 +554,69 @@ function handleSessionOutput(data) { const content = data.data.content || ''; + // ============================================================ + // SEMANTIC VALIDATION - Detect confusing UX messages + // ============================================================ + if (window.semanticValidator && content) { + const confusingOutput = window.semanticValidator.detectConfusingOutput(content); + if (confusingOutput) { + // Report to bug tracker + window.semanticValidator.reportSemanticError(confusingOutput); + + // Log for debugging + console.warn('[SemanticValidator] Confusing UX message detected:', confusingOutput); + } + } + + // ============================================================ + // APPROVAL REQUEST DETECTION - Intercept AI approval requests + // ============================================================ + // Check if AI is asking for approval + const approvalRequest = detectApprovalRequest(content); + if (approvalRequest && window.ApprovalCard) { + console.log('[ApprovalRequest] Detected:', approvalRequest); + + // Don't show the raw AI message - show approval card instead + // But we need to still show the explanation part + + // Check if we already have a pending approval for this command + const existingCard = document.querySelector(`[data-pending-command="${escapeHtml(approvalRequest.command)}"]`); + if (!existingCard) { + // Generate a unique ID for this approval + const approvalId = `approval-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // Render approval card + const card = window.ApprovalCard.render({ + id: approvalId, + sessionId: data.sessionId, + command: approvalRequest.command, + explanation: approvalRequest.explanation || 'AI agent requests approval to execute this command' + }); + + // Add tracking attribute + card.dataset.pendingCommand = approvalRequest.command; + + // Add to chat + const messagesContainer = document.getElementById('chat-messages'); + if (messagesContainer) { + messagesContainer.appendChild(card); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // Store the approval for later response + window._pendingApprovals = window._pendingApprovals || {}; + window._pendingApprovals[approvalId] = { + command: approvalRequest.command, + sessionId: data.sessionId, + originalMessage: content + }; + + // Don't add the AI's raw text to the chat + // Just skip to next iteration + return; + } + } + // Accumulate streaming content if (streamingMessageElement && streamingMessageElement.isConnected) { // Append to existing message @@ -436,8 +643,50 @@ function handleSessionOutput(data) { if (typeof setGeneratingState === 'function') { setGeneratingState(false); } + + // ============================================================ + // COMMAND TRACKER - Complete command execution + // ============================================================ + if (window.commandTracker && window._pendingCommandId) { + // Try to extract exit code from the last output + const exitCode = extractExitCode(streamingMessageContent); + + // Complete the command tracking + window.commandTracker.completeCommand( + window._pendingCommandId, + exitCode, + streamingMessageContent + ); + + // Clear pending command ID + window._pendingCommandId = null; + + console.log('[CommandTracker] Command completed via stream timeout'); + } }, 1000); } + + /** + * Extract exit code from command output + * @param {string} output - Command output + * @returns {number|null} - Exit code or null + */ + function extractExitCode(output) { + if (!output) return null; + + // Look for exit code patterns + const exitCodeMatch = output.match(/exited with code (\d+|undefined|null)/i); + if (exitCodeMatch) { + const code = exitCodeMatch[1]; + if (code === 'undefined' || code === 'null') { + return null; // Undefined/null exit code + } + return parseInt(code, 10); + } + + // If no explicit exit code found, assume success (0) + return 0; + } } // Dashboard diff --git a/public/claude-ide/index.html b/public/claude-ide/index.html index 323b3c1e..195bbd0f 100644 --- a/public/claude-ide/index.html +++ b/public/claude-ide/index.html @@ -13,6 +13,7 @@ + @@ -345,6 +346,9 @@ + + + diff --git a/server.js b/server.js index 3694d31e..059501b2 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,7 @@ const session = require('express-session'); const bcrypt = require('bcryptjs'); const path = require('path'); const fs = require('fs'); +const os = require('os'); const MarkdownIt = require('markdown-it'); const hljs = require('highlight.js'); const WebSocket = require('ws'); @@ -11,6 +12,10 @@ const terminalService = require('./services/terminal-service'); const { db } = require('./services/database'); const app = express(); + +// Store recent browser errors for debugging +const recentErrors = []; + const md = new MarkdownIt({ html: true, linkify: true, @@ -40,6 +45,162 @@ const users = { // Initialize Claude Code Service const claudeService = new ClaudeCodeService(VAULT_PATH); +// ============================================================ +// Pending Approvals Manager +// ============================================================ + +class PendingApprovalsManager { + constructor() { + this.approvals = new Map(); + this.cleanupInterval = setInterval(() => this.cleanup(), 60000); // Cleanup every minute + } + + /** + * Create a new pending approval + * @param {string} sessionId - Session ID requesting approval + * @param {string} command - Command awaiting approval + * @param {string} explanation - Human-readable explanation + * @returns {string} Approval ID + */ + createApproval(sessionId, command, explanation) { + const id = `approval-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const approval = { + id, + sessionId, + command, + explanation, + createdAt: Date.now(), + expiresAt: Date.now() + (5 * 60 * 1000) // 5 minutes + }; + + this.approvals.set(id, approval); + + console.log(`[ApprovalManager] Created approval ${id} for session ${sessionId}`); + + // Auto-expire after 5 minutes + setTimeout(() => { + this.expire(id); + }, 5 * 60 * 1000); + + return id; + } + + /** + * Get approval by ID + * @param {string} id - Approval ID + * @returns {object|null} Approval object or null if not found/expired + */ + getApproval(id) { + const approval = this.approvals.get(id); + + if (!approval) { + return null; + } + + // Check if expired + if (Date.now() > approval.expiresAt) { + this.approvals.delete(id); + return null; + } + + return approval; + } + + /** + * Remove approval (approved/rejected/expired) + * @param {string} id - Approval ID + */ + removeApproval(id) { + const removed = this.approvals.delete(id); + if (removed) { + console.log(`[ApprovalManager] Removed approval ${id}`); + } + return removed; + } + + /** + * Mark approval as expired + * @param {string} id - Approval ID + */ + expire(id) { + const approval = this.approvals.get(id); + if (approval && Date.now() < approval.expiresAt) { + // Not actually expired yet, don't remove + return; + } + + this.removeApproval(id); + console.log(`[ApprovalManager] Approval ${id} expired`); + + // Notify session about expiration + claudeService.emit('approval-expired', { id, sessionId: approval?.sessionId }); + } + + /** + * Clean up expired approvals + */ + cleanup() { + const now = Date.now(); + let expiredCount = 0; + + for (const [id, approval] of this.approvals.entries()) { + if (now > approval.expiresAt) { + this.approvals.delete(id); + expiredCount++; + } + } + + if (expiredCount > 0) { + console.log(`[ApprovalManager] Cleaned up ${expiredCount} expired approvals`); + } + } + + /** + * Get all pending approvals for a session + * @param {string} sessionId - Session ID + * @returns {Array} Array of approval objects + */ + getApprovalsForSession(sessionId) { + const sessionApprovals = []; + + for (const [id, approval] of this.approvals.entries()) { + if (approval.sessionId === sessionId && Date.now() < approval.expiresAt) { + sessionApprovals.push(approval); + } + } + + return sessionApprovals; + } + + /** + * Check if session has any pending approvals + * @param {string} sessionId - Session ID + * @returns {boolean} True if session has pending approvals + */ + hasPendingApproval(sessionId) { + for (const [id, approval] of this.approvals.entries()) { + if (approval.sessionId === sessionId && Date.now() < approval.expiresAt) { + return true; + } + } + return false; + } + + /** + * Get statistics + */ + getStats() { + return { + total: this.approvals.size, + pending: Array.from(this.approvals.values()).filter(a => Date.now() < a.expiresAt).length + }; + } +} + +// Initialize approval manager +const approvalManager = new PendingApprovalsManager(); + // Cleanup old sessions every hour setInterval(() => { claudeService.cleanup(); @@ -1193,6 +1354,15 @@ function validateProjectPath(projectPath) { return { valid: true, path: resolvedPath }; } +// GET /api/debug/errors - View recent browser errors (debug only) +app.get('/api/debug/errors', (req, res) => { + const errors = recentErrors.slice(-20); // Last 20 errors + res.json({ + total: recentErrors.length, + recent: errors + }); +}); + // GET /api/projects - List all active projects app.get('/api/projects', requireAuth, (req, res) => { try { @@ -1203,11 +1373,20 @@ app.get('/api/projects', requireAuth, (req, res) => { ORDER BY lastActivity DESC `).all(); - // Add sessionCount (0 for now, will be implemented in Task 3) - const projectsWithSessionCount = projects.map(project => ({ - ...project, - sessionCount: 0 - })); + // Add sessionCount for each project + const projectsWithSessionCount = projects.map(project => { + const sessionCount = db.prepare(` + SELECT COUNT(*) as count + FROM sessions + WHERE projectId = ? AND deletedAt IS NULL + `).get(project.id); + + return { + ...project, + sessionCount: sessionCount?.count || 0, + sources: ['web'] // TODO: Track CLI vs Web sources + }; + }); res.json({ success: true, @@ -1496,6 +1675,133 @@ app.delete('/api/projects/:id/permanent', requireAuth, (req, res) => { } }); +// GET /api/projects/:id/sessions - Get all sessions for a project +app.get('/api/projects/:id/sessions', requireAuth, (req, res) => { + try { + const { id } = req.params; + + // Validate ID + const validatedId = validateProjectId(id); + if (!validatedId) { + return res.status(400).json({ error: 'Invalid project ID' }); + } + + // Check if project exists + const project = db.prepare(` + SELECT id, name FROM projects + WHERE id = ? AND deletedAt IS NULL + `).get(validatedId); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Get sessions from in-memory Claude service and historical + const allSessions = []; + + // Get active sessions + const activeSessions = Array.from(claudeService.sessions.values()) + .filter(session => session.projectId == validatedId) + .map(session => ({ + id: session.id, + title: session.title || session.metadata?.project || 'Untitled Session', + agent: session.metadata?.agent || 'claude', + createdAt: new Date(session.createdAt).toISOString(), + updatedAt: new Date(session.lastActivity || session.createdAt).toISOString(), + status: 'active', + metadata: session.metadata + })); + + // Get historical sessions from DB + const historicalSessions = db.prepare(` + SELECT id, createdAt, metadata + FROM sessions + WHERE projectId = ? AND deletedAt IS NULL + ORDER BY createdAt DESC + `).all(validatedId).map(session => ({ + id: session.id, + title: session.metadata?.project || 'Untitled Session', + agent: session.metadata?.agent || 'claude', + createdAt: session.createdAt, + updatedAt: session.createdAt, + status: 'historical', + metadata: typeof session.metadata === 'string' ? JSON.parse(session.metadata) : session.metadata + })); + + // Merge and deduplicate + const sessionMap = new Map(); + [...activeSessions, ...historicalSessions].forEach(session => { + if (!sessionMap.has(session.id)) { + sessionMap.set(session.id, session); + } + }); + + const sessions = Array.from(sessionMap.values()) + .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + + res.json({ + success: true, + sessions + }); + } catch (error) { + console.error('Error listing project sessions:', error); + res.status(500).json({ error: 'Failed to list sessions' }); + } +}); + +// POST /api/projects/:id/sessions - Create new session in project +app.post('/api/projects/:id/sessions', requireAuth, async (req, res) => { + try { + const { id } = req.params; + const { metadata = {} } = req.body; + + // Validate ID + const validatedId = validateProjectId(id); + if (!validatedId) { + return res.status(400).json({ error: 'Invalid project ID' }); + } + + // Check if project exists + const project = db.prepare(` + SELECT id, name, path FROM projects + WHERE id = ? AND deletedAt IS NULL + `).get(validatedId); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Create session using Claude service + const session = await claudeService.createSession(project.path, { + ...metadata, + project: project.name, + projectId: validatedId + }); + + // Update project lastActivity + db.prepare(` + UPDATE projects + SET lastActivity = ? + WHERE id = ? + `).run(new Date().toISOString(), validatedId); + + res.json({ + success: true, + session: { + id: session.id, + title: session.title || session.metadata?.project || 'New Session', + agent: session.metadata?.agent || 'claude', + createdAt: session.createdAt, + updatedAt: session.lastActivity || session.createdAt, + metadata: session.metadata + } + }); + } catch (error) { + console.error('Error creating session:', error); + res.status(500).json({ error: 'Failed to create session' }); + } +}); + // GET /api/recycle-bin - List deleted items app.get('/api/recycle-bin', requireAuth, (req, res) => { try { @@ -1987,6 +2293,71 @@ wss.on('connection', (ws, req) => { error: error.message })); } + } else if (data.type === 'approval-request') { + // AI agent requesting approval for a command + const { sessionId, command, explanation } = data; + + console.log(`[WebSocket] Approval request for session ${sessionId}: ${command}`); + + // Create pending approval + const approvalId = approvalManager.createApproval(sessionId, command, explanation); + + // Forward approval request to all clients subscribed to this session + clients.forEach((client) => { + if (client.sessionId === sessionId && client.ws.readyState === WebSocket.OPEN) { + client.ws.send(JSON.stringify({ + type: 'approval-request', + id: approvalId, + sessionId, + command, + explanation + })); + } + }); + + } else if (data.type === 'approval-response') { + // User responded to approval request + const { id, approved, customCommand } = data; + + console.log(`[WebSocket] Approval response for ${id}: approved=${approved}`); + + // Get the approval + const approval = approvalManager.getApproval(id); + + if (!approval) { + console.error(`[WebSocket] Approval ${id} not found or expired`); + ws.send(JSON.stringify({ + type: 'approval-expired', + id + })); + return; + } + + // Remove from pending + approvalManager.removeApproval(id); + + // Send approval as a message to the AI agent + const approvalMessage = approved + ? (customCommand + ? `[USER APPROVED WITH MODIFICATION: ${customCommand}]` + : `[USER APPROVED: ${approval.command}]`) + : `[USER REJECTED: ${approval.command}]`; + + // Add to session context + claudeService.sendCommand(approval.sessionId, approvalMessage); + + // Forward response confirmation to clients + clients.forEach((client) => { + if (client.sessionId === approval.sessionId && client.ws.readyState === WebSocket.OPEN) { + client.ws.send(JSON.stringify({ + type: 'approval-confirmed', + id, + approved, + customCommand: customCommand || null + })); + } + }); + } else if (data.type === 'subscribe') { // Subscribe to a specific session's output const { sessionId } = data; @@ -2105,6 +2476,50 @@ claudeService.on('operations-error', (data) => { }); }); +// Forward approval-request event +claudeService.on('approval-request', (data) => { + console.log(`Approval request for session ${data.sessionId}:`, data.command); + + // Create pending approval + const approvalId = approvalManager.createApproval(data.sessionId, data.command, data.explanation); + + // Forward to all clients subscribed to this session + clients.forEach((client, clientId) => { + if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) { + try { + client.ws.send(JSON.stringify({ + type: 'approval-request', + id: approvalId, + sessionId: data.sessionId, + command: data.command, + explanation: data.explanation + })); + } catch (error) { + console.error(`Error sending approval request to client ${clientId}:`, error); + } + } + }); +}); + +// Forward approval-expired event +claudeService.on('approval-expired', (data) => { + console.log(`Approval expired for session ${data.sessionId}:`, data.id); + + // Forward to all clients subscribed to this session + clients.forEach((client, clientId) => { + if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) { + try { + client.ws.send(JSON.stringify({ + type: 'approval-expired', + id: data.id + })); + } catch (error) { + console.error(`Error sending approval-expired to client ${clientId}:`, error); + } + } + }); +}); + console.log(`WebSocket server running on ws://localhost:${PORT}/claude/api/claude/chat`); // Real-time error monitoring endpoint @@ -2128,6 +2543,13 @@ app.post('/claude/api/log-error', express.json(), (req, res) => { const errorEntry = JSON.stringify({ ...error, loggedAt: timestamp }) + '\n'; fs.appendFileSync(errorLogPath, errorEntry); + // Store in recent errors array for debug endpoint + recentErrors.push({ ...error, loggedAt: timestamp }); + // Keep only last 100 errors + if (recentErrors.length > 100) { + recentErrors.shift(); + } + // 🤖 AUTO-TRIGGER: Spawn auto-fix agent in background const { spawn } = require('child_process'); const agentPath = '/home/uroma/obsidian-web-interface/scripts/auto-fix-agent.js'; @@ -2157,6 +2579,284 @@ app.post('/claude/api/log-error', express.json(), (req, res) => { res.json({ received: true, autoFixTriggered: true }); }); +// ============================================================ +// FILESYSTEM API - Folder Explorer +// ============================================================ + +/** + * Expand ~ to home directory and validate path + */ +function expandPath(userPath) { + const homeDir = os.homedir(); + + // Expand ~ or ~user + let expanded = userPath.replace(/^~\/?/, homeDir + '/'); + + // Resolve to absolute path + expanded = path.resolve(expanded); + + // Security: Must be under home directory + if (!expanded.startsWith(homeDir)) { + throw new Error('Access denied: Path outside home directory'); + } + + return expanded; +} + +/** + * Get directory contents for folder tree + */ +app.get('/api/filesystem/list', requireAuth, async (req, res) => { + try { + const { path: userPath = '~' } = req.query; + + // Expand and validate path + const expandedPath = expandPath(userPath); + + // Check if path exists + try { + await fs.promises.access(expandedPath, fs.constants.R_OK); + } catch { + return res.json({ + success: false, + error: 'Directory not found or inaccessible', + path: expandedPath + }); + } + + // Read directory + const entries = await fs.promises.readdir(expandedPath, { withFileTypes: true }); + + // Filter directories only, sort, and get metadata + const items = []; + for (const entry of entries) { + if (entry.isDirectory()) { + const fullPath = path.join(expandedPath, entry.name); + + // Skip hidden directories (except .git) + if (entry.name.startsWith('.') && entry.name !== '.git') { + continue; + } + + try { + const subEntries = await fs.promises.readdir(fullPath, { withFileTypes: true }); + const subDirs = subEntries.filter(e => e.isDirectory()).length; + + items.push({ + name: entry.name, + type: 'directory', + path: fullPath, + children: subDirs, + hasChildren: subDirs > 0 + }); + } catch { + // Can't read subdirectory (permissions) + items.push({ + name: entry.name, + type: 'directory', + path: fullPath, + children: 0, + hasChildren: false, + locked: true + }); + } + } + } + + // Sort alphabetically + items.sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ + success: true, + path: expandedPath, + displayPath: expandedPath.replace(os.homedir(), '~'), + items + }); + } catch (error) { + console.error('Error listing directory:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +/** + * Validate if path exists and is accessible + */ +app.get('/api/filesystem/validate', requireAuth, async (req, res) => { + try { + const { path: userPath } = req.query; + + if (!userPath) { + return res.status(400).json({ + success: false, + error: 'Path parameter is required' + }); + } + + // Expand and validate path + const expandedPath = expandPath(userPath); + + // Check if path exists + try { + await fs.promises.access(expandedPath, fs.constants.F_OK); + } catch { + return res.json({ + success: true, + valid: false, + path: expandedPath, + displayPath: expandedPath.replace(os.homedir(), '~'), + exists: false + }); + } + + // Check read access + let readable = false; + try { + await fs.promises.access(expandedPath, fs.constants.R_OK); + readable = true; + } catch {} + + // Check write access + let writable = false; + try { + await fs.promises.access(expandedPath, fs.constants.W_OK); + writable = true; + } catch {} + + // Check if it's a directory + const stats = await fs.promises.stat(expandedPath); + const isDirectory = stats.isDirectory(); + + res.json({ + success: true, + valid: true, + path: expandedPath, + displayPath: expandedPath.replace(os.homedir(), '~'), + exists: true, + readable, + writable, + isDirectory + }); + } catch (error) { + // Path validation error + res.json({ + success: true, + valid: false, + error: error.message, + path: userPath + }); + } +}); + +/** + * Create new directory + */ +app.post('/api/filesystem/mkdir', requireAuth, async (req, res) => { + try { + const { path: userPath } = req.body; + + if (!userPath) { + return res.status(400).json({ + success: false, + error: 'Path parameter is required' + }); + } + + // Expand and validate path + const expandedPath = expandPath(userPath); + + // Validate folder name (no special characters) + const folderName = path.basename(expandedPath); + if (!/^[a-zA-Z0-9._-]+$/.test(folderName)) { + return res.status(400).json({ + success: false, + error: 'Invalid folder name. Use only letters, numbers, dots, dashes, and underscores.' + }); + } + + // Check if already exists + try { + await fs.access(expandedPath, fs.constants.F_OK); + return res.status(400).json({ + success: false, + error: 'Directory already exists' + }); + } catch { + // Doesn't exist, good to create + } + + // Create directory + await fs.promises.mkdir(expandedPath, { mode: 0o755 }); + + res.json({ + success: true, + path: expandedPath, + displayPath: expandedPath.replace(os.homedir(), '~') + }); + } catch (error) { + console.error('Error creating directory:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +/** + * Get quick access locations + */ +app.get('/api/filesystem/quick-access', requireAuth, async (req, res) => { + try { + const homeDir = os.homedir(); + const locations = [ + { path: '~', name: 'Home', icon: '🏠' }, + { path: '~/projects', name: 'Projects', icon: '📂' }, + { path: '~/code', name: 'Code', icon: 'đŸ’ģ' }, + { path: '~/Documents', name: 'Documents', icon: '📄' }, + { path: '~/Downloads', name: 'Downloads', icon: 'đŸ“Ĩ' } + ]; + + // Check which locations exist and get item counts + const enrichedLocations = await Promise.all(locations.map(async (loc) => { + try { + const expandedPath = expandPath(loc.path); + await fs.promises.access(expandedPath, fs.constants.R_OK); + + // Get item count + const entries = await fs.promises.readdir(expandedPath, { withFileTypes: true }); + const count = entries.filter(e => e.isDirectory()).length; + + return { + ...loc, + path: expandedPath, + displayPath: expandedPath.replace(homeDir, '~'), + exists: true, + count + }; + } catch { + return { + ...loc, + exists: false, + count: 0 + }; + } + })); + + res.json({ + success: true, + locations: enrichedLocations + }); + } catch (error) { + console.error('Error getting quick access locations:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + // Graceful shutdown process.on('SIGINT', async () => { console.log('\nShutting down gracefully...'); diff --git a/services/claude-service.js b/services/claude-service.js index e7b0edaa..1c94eccc 100644 --- a/services/claude-service.js +++ b/services/claude-service.js @@ -293,6 +293,38 @@ class ClaudeCodeService extends EventEmitter { return { success: true }; } + /** + * Request approval for a command (instead of executing directly) + * @param {string} sessionId - Session ID + * @param {string} command - Command requiring approval + * @param {string} explanation - Human-readable explanation of what will happen + */ + requestApproval(sessionId, command, explanation) { + const session = this.sessions.get(sessionId); + + if (!session) { + throw new Error(`Session ${sessionId} not found`); + } + + console.log(`[ClaudeService] Requesting approval for session ${sessionId}:`, command); + + // Track the pending request in session context + session.context.messages.push({ + role: 'system', + content: `[AWAITING APPROVAL: ${command}]`, + timestamp: new Date().toISOString() + }); + + // Emit approval-request event for WebSocket to handle + this.emit('approval-request', { + sessionId, + command, + explanation + }); + + return { success: true, awaitingApproval: true }; + } + /** * Get session details (handles both active and historical sessions) */