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:
@@ -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) {
|
||||||
|
|||||||
265
public/claude-ide/components/approval-card.css
Normal file
265
public/claude-ide/components/approval-card.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
267
public/claude-ide/components/approval-card.js
Normal file
267
public/claude-ide/components/approval-card.js
Normal 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');
|
||||||
|
})();
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
710
server.js
@@ -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...');
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user