Implement terminal approval UI system

Phase 1: Backend approval tracking
- Add PendingApprovalsManager class to track pending approvals
- Add approval-request, approval-response, approval-expired WebSocket handlers
- Add requestApproval() method to ClaudeCodeService
- Add event forwarding for approval requests

Phase 2: Frontend approval card component
- Create approval-card.js with interactive UI
- Create approval-card.css with styled component
- Add Approve, Custom Instructions, Reject buttons
- Add expandable custom command input

Phase 3: Wire up approval flow end-to-end
- Add handleApprovalRequest, handleApprovalConfirmed, handleApprovalExpired handlers
- Add detectApprovalRequest() to parse AI approval request patterns
- Integrate approval card into WebSocket message flow
- Route approval responses based on source (server vs AI conversational)

This allows the AI agent to request command approval through a clean
UI instead of confusing conversational text responses.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
uroma
2026-01-21 14:24:13 +00:00
Unverified
parent 153e365c7b
commit a45b71e1e4
7 changed files with 1564 additions and 15 deletions

View File

@@ -14,6 +14,12 @@
activityLog: [], // New: stores AI activity stream activityLog: [], // New: stores AI activity stream
addError(error) { 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 errorId = this.generateErrorId(error);
const existingError = this.errors.find(e => e.id === errorId); const existingError = this.errors.find(e => e.id === errorId);
@@ -221,17 +227,43 @@
triggerManualFix(errorId) { triggerManualFix(errorId) {
const error = this.errors.find(e => e.id === errorId); const error = this.errors.find(e => e.id === errorId);
if (error) { if (!error) {
// Report to server to trigger fix console.error('[BugTracker] Error not found:', errorId);
fetch('/claude/api/log-error', { return;
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...error,
manualTrigger: true
})
});
} }
// 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) { getTimeAgo(timestamp) {

View File

@@ -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;
}
}

View File

@@ -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 = `
<div class="approval-card-header">
<span class="approval-icon">🤖</span>
<span class="approval-label">Executing:</span>
<code class="approval-command">${escapeHtml(approvalData.command)}</code>
</div>
${approvalData.explanation ? `
<div class="approval-explanation">
<span class="explanation-icon"></span>
<span class="explanation-text">${escapeHtml(approvalData.explanation)}</span>
</div>
` : ''}
<div class="approval-buttons">
<button class="btn-approve" onclick="ApprovalCard.handleApprove('${approvalData.id}')">Approve</button>
<button class="btn-custom" onclick="ApprovalCard.handleCustom('${approvalData.id}')" ${approvalData.explanation ? '' : 'style="display:none"'}>Custom Instructions</button>
<button class="btn-reject" onclick="ApprovalCard.handleReject('${approvalData.id}')">Reject</button>
</div>
<div class="approval-custom" style="display:none;">
<label class="custom-label">Custom command:</label>
<input type="text" class="custom-input" id="${cardId}-custom-input" placeholder="Enter modified command..." />
<div class="custom-buttons">
<button class="btn-approve-small" onclick="ApprovalCard.executeCustom('${approvalData.id}')">Execute Custom</button>
<button class="btn-cancel-small" onclick="ApprovalCard.closeCustom('${approvalData.id}')">Cancel</button>
</div>
</div>
`;
// 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 = `
<span class="approval-icon" style="color: #ff6b6b;">⏱️</span>
<span class="approval-label">Expired:</span>
<span class="approval-command" style="color: #ff6b6b;">This approval request has expired</span>
`;
}
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');
})();

View File

@@ -291,6 +291,15 @@ function handleWebSocketMessage(data) {
case 'operation-progress': case 'operation-progress':
handleOperationProgress(data); handleOperationProgress(data);
break; break;
case 'approval-request':
handleApprovalRequest(data);
break;
case 'approval-confirmed':
handleApprovalConfirmed(data);
break;
case 'approval-expired':
handleApprovalExpired(data);
break;
case 'error': case 'error':
console.error('WebSocket error:', data.error); console.error('WebSocket error:', data.error);
// Show error in chat if attached // 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 // Streaming message state for accumulating response chunks
let streamingMessageElement = null; let streamingMessageElement = null;
let streamingMessageContent = ''; let streamingMessageContent = '';
@@ -410,6 +554,69 @@ function handleSessionOutput(data) {
const content = data.data.content || ''; 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 // Accumulate streaming content
if (streamingMessageElement && streamingMessageElement.isConnected) { if (streamingMessageElement && streamingMessageElement.isConnected) {
// Append to existing message // Append to existing message
@@ -436,8 +643,50 @@ function handleSessionOutput(data) {
if (typeof setGeneratingState === 'function') { if (typeof setGeneratingState === 'function') {
setGeneratingState(false); 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); }, 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 // Dashboard

View File

@@ -13,6 +13,7 @@
<link rel="stylesheet" href="/claude/claude-ide/components/monaco-editor.css"> <link rel="stylesheet" href="/claude/claude-ide/components/monaco-editor.css">
<link rel="stylesheet" href="/claude/claude-ide/components/enhanced-chat-input.css"> <link rel="stylesheet" href="/claude/claude-ide/components/enhanced-chat-input.css">
<link rel="stylesheet" href="/claude/claude-ide/components/session-picker.css"> <link rel="stylesheet" href="/claude/claude-ide/components/session-picker.css">
<link rel="stylesheet" href="/claude/claude-ide/components/approval-card.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<!-- Monaco Editor (VS Code Editor) - AMD Loader --> <!-- Monaco Editor (VS Code Editor) - AMD Loader -->
@@ -345,6 +346,9 @@
</div> </div>
<script src="/claude/claude-ide/error-monitor.js"></script> <script src="/claude/claude-ide/error-monitor.js"></script>
<script src="/claude/claude-ide/semantic-validator.js"></script>
<script src="/claude/claude-ide/components/approval-card.js"></script>
<script src="/claude/claude-ide/command-tracker.js"></script>
<script src="/claude/claude-ide/bug-tracker.js"></script> <script src="/claude/claude-ide/bug-tracker.js"></script>
<script src="/claude/claude-ide/ide.js"></script> <script src="/claude/claude-ide/ide.js"></script>
<script src="/claude/claude-ide/chat-functions.js"></script> <script src="/claude/claude-ide/chat-functions.js"></script>

710
server.js
View File

@@ -3,6 +3,7 @@ const session = require('express-session');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const MarkdownIt = require('markdown-it'); const MarkdownIt = require('markdown-it');
const hljs = require('highlight.js'); const hljs = require('highlight.js');
const WebSocket = require('ws'); const WebSocket = require('ws');
@@ -11,6 +12,10 @@ const terminalService = require('./services/terminal-service');
const { db } = require('./services/database'); const { db } = require('./services/database');
const app = express(); const app = express();
// Store recent browser errors for debugging
const recentErrors = [];
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, html: true,
linkify: true, linkify: true,
@@ -40,6 +45,162 @@ const users = {
// Initialize Claude Code Service // Initialize Claude Code Service
const claudeService = new ClaudeCodeService(VAULT_PATH); 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 // Cleanup old sessions every hour
setInterval(() => { setInterval(() => {
claudeService.cleanup(); claudeService.cleanup();
@@ -1193,6 +1354,15 @@ function validateProjectPath(projectPath) {
return { valid: true, path: resolvedPath }; 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 // GET /api/projects - List all active projects
app.get('/api/projects', requireAuth, (req, res) => { app.get('/api/projects', requireAuth, (req, res) => {
try { try {
@@ -1203,11 +1373,20 @@ app.get('/api/projects', requireAuth, (req, res) => {
ORDER BY lastActivity DESC ORDER BY lastActivity DESC
`).all(); `).all();
// Add sessionCount (0 for now, will be implemented in Task 3) // Add sessionCount for each project
const projectsWithSessionCount = projects.map(project => ({ const projectsWithSessionCount = projects.map(project => {
...project, const sessionCount = db.prepare(`
sessionCount: 0 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({ res.json({
success: true, 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 // GET /api/recycle-bin - List deleted items
app.get('/api/recycle-bin', requireAuth, (req, res) => { app.get('/api/recycle-bin', requireAuth, (req, res) => {
try { try {
@@ -1987,6 +2293,71 @@ wss.on('connection', (ws, req) => {
error: error.message 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') { } else if (data.type === 'subscribe') {
// Subscribe to a specific session's output // Subscribe to a specific session's output
const { sessionId } = data; 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`); console.log(`WebSocket server running on ws://localhost:${PORT}/claude/api/claude/chat`);
// Real-time error monitoring endpoint // 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'; const errorEntry = JSON.stringify({ ...error, loggedAt: timestamp }) + '\n';
fs.appendFileSync(errorLogPath, errorEntry); 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 // 🤖 AUTO-TRIGGER: Spawn auto-fix agent in background
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const agentPath = '/home/uroma/obsidian-web-interface/scripts/auto-fix-agent.js'; 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 }); 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 // Graceful shutdown
process.on('SIGINT', async () => { process.on('SIGINT', async () => {
console.log('\nShutting down gracefully...'); console.log('\nShutting down gracefully...');

View File

@@ -293,6 +293,38 @@ class ClaudeCodeService extends EventEmitter {
return { success: true }; 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) * Get session details (handles both active and historical sessions)
*/ */