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

710
server.js
View File

@@ -3,6 +3,7 @@ const session = require('express-session');
const bcrypt = require('bcryptjs');
const path = require('path');
const fs = require('fs');
const os = require('os');
const MarkdownIt = require('markdown-it');
const hljs = require('highlight.js');
const WebSocket = require('ws');
@@ -11,6 +12,10 @@ const terminalService = require('./services/terminal-service');
const { db } = require('./services/database');
const app = express();
// Store recent browser errors for debugging
const recentErrors = [];
const md = new MarkdownIt({
html: true,
linkify: true,
@@ -40,6 +45,162 @@ const users = {
// Initialize Claude Code Service
const claudeService = new ClaudeCodeService(VAULT_PATH);
// ============================================================
// Pending Approvals Manager
// ============================================================
class PendingApprovalsManager {
constructor() {
this.approvals = new Map();
this.cleanupInterval = setInterval(() => this.cleanup(), 60000); // Cleanup every minute
}
/**
* Create a new pending approval
* @param {string} sessionId - Session ID requesting approval
* @param {string} command - Command awaiting approval
* @param {string} explanation - Human-readable explanation
* @returns {string} Approval ID
*/
createApproval(sessionId, command, explanation) {
const id = `approval-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const approval = {
id,
sessionId,
command,
explanation,
createdAt: Date.now(),
expiresAt: Date.now() + (5 * 60 * 1000) // 5 minutes
};
this.approvals.set(id, approval);
console.log(`[ApprovalManager] Created approval ${id} for session ${sessionId}`);
// Auto-expire after 5 minutes
setTimeout(() => {
this.expire(id);
}, 5 * 60 * 1000);
return id;
}
/**
* Get approval by ID
* @param {string} id - Approval ID
* @returns {object|null} Approval object or null if not found/expired
*/
getApproval(id) {
const approval = this.approvals.get(id);
if (!approval) {
return null;
}
// Check if expired
if (Date.now() > approval.expiresAt) {
this.approvals.delete(id);
return null;
}
return approval;
}
/**
* Remove approval (approved/rejected/expired)
* @param {string} id - Approval ID
*/
removeApproval(id) {
const removed = this.approvals.delete(id);
if (removed) {
console.log(`[ApprovalManager] Removed approval ${id}`);
}
return removed;
}
/**
* Mark approval as expired
* @param {string} id - Approval ID
*/
expire(id) {
const approval = this.approvals.get(id);
if (approval && Date.now() < approval.expiresAt) {
// Not actually expired yet, don't remove
return;
}
this.removeApproval(id);
console.log(`[ApprovalManager] Approval ${id} expired`);
// Notify session about expiration
claudeService.emit('approval-expired', { id, sessionId: approval?.sessionId });
}
/**
* Clean up expired approvals
*/
cleanup() {
const now = Date.now();
let expiredCount = 0;
for (const [id, approval] of this.approvals.entries()) {
if (now > approval.expiresAt) {
this.approvals.delete(id);
expiredCount++;
}
}
if (expiredCount > 0) {
console.log(`[ApprovalManager] Cleaned up ${expiredCount} expired approvals`);
}
}
/**
* Get all pending approvals for a session
* @param {string} sessionId - Session ID
* @returns {Array} Array of approval objects
*/
getApprovalsForSession(sessionId) {
const sessionApprovals = [];
for (const [id, approval] of this.approvals.entries()) {
if (approval.sessionId === sessionId && Date.now() < approval.expiresAt) {
sessionApprovals.push(approval);
}
}
return sessionApprovals;
}
/**
* Check if session has any pending approvals
* @param {string} sessionId - Session ID
* @returns {boolean} True if session has pending approvals
*/
hasPendingApproval(sessionId) {
for (const [id, approval] of this.approvals.entries()) {
if (approval.sessionId === sessionId && Date.now() < approval.expiresAt) {
return true;
}
}
return false;
}
/**
* Get statistics
*/
getStats() {
return {
total: this.approvals.size,
pending: Array.from(this.approvals.values()).filter(a => Date.now() < a.expiresAt).length
};
}
}
// Initialize approval manager
const approvalManager = new PendingApprovalsManager();
// Cleanup old sessions every hour
setInterval(() => {
claudeService.cleanup();
@@ -1193,6 +1354,15 @@ function validateProjectPath(projectPath) {
return { valid: true, path: resolvedPath };
}
// GET /api/debug/errors - View recent browser errors (debug only)
app.get('/api/debug/errors', (req, res) => {
const errors = recentErrors.slice(-20); // Last 20 errors
res.json({
total: recentErrors.length,
recent: errors
});
});
// GET /api/projects - List all active projects
app.get('/api/projects', requireAuth, (req, res) => {
try {
@@ -1203,11 +1373,20 @@ app.get('/api/projects', requireAuth, (req, res) => {
ORDER BY lastActivity DESC
`).all();
// Add sessionCount (0 for now, will be implemented in Task 3)
const projectsWithSessionCount = projects.map(project => ({
...project,
sessionCount: 0
}));
// Add sessionCount for each project
const projectsWithSessionCount = projects.map(project => {
const sessionCount = db.prepare(`
SELECT COUNT(*) as count
FROM sessions
WHERE projectId = ? AND deletedAt IS NULL
`).get(project.id);
return {
...project,
sessionCount: sessionCount?.count || 0,
sources: ['web'] // TODO: Track CLI vs Web sources
};
});
res.json({
success: true,
@@ -1496,6 +1675,133 @@ app.delete('/api/projects/:id/permanent', requireAuth, (req, res) => {
}
});
// GET /api/projects/:id/sessions - Get all sessions for a project
app.get('/api/projects/:id/sessions', requireAuth, (req, res) => {
try {
const { id } = req.params;
// Validate ID
const validatedId = validateProjectId(id);
if (!validatedId) {
return res.status(400).json({ error: 'Invalid project ID' });
}
// Check if project exists
const project = db.prepare(`
SELECT id, name FROM projects
WHERE id = ? AND deletedAt IS NULL
`).get(validatedId);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
// Get sessions from in-memory Claude service and historical
const allSessions = [];
// Get active sessions
const activeSessions = Array.from(claudeService.sessions.values())
.filter(session => session.projectId == validatedId)
.map(session => ({
id: session.id,
title: session.title || session.metadata?.project || 'Untitled Session',
agent: session.metadata?.agent || 'claude',
createdAt: new Date(session.createdAt).toISOString(),
updatedAt: new Date(session.lastActivity || session.createdAt).toISOString(),
status: 'active',
metadata: session.metadata
}));
// Get historical sessions from DB
const historicalSessions = db.prepare(`
SELECT id, createdAt, metadata
FROM sessions
WHERE projectId = ? AND deletedAt IS NULL
ORDER BY createdAt DESC
`).all(validatedId).map(session => ({
id: session.id,
title: session.metadata?.project || 'Untitled Session',
agent: session.metadata?.agent || 'claude',
createdAt: session.createdAt,
updatedAt: session.createdAt,
status: 'historical',
metadata: typeof session.metadata === 'string' ? JSON.parse(session.metadata) : session.metadata
}));
// Merge and deduplicate
const sessionMap = new Map();
[...activeSessions, ...historicalSessions].forEach(session => {
if (!sessionMap.has(session.id)) {
sessionMap.set(session.id, session);
}
});
const sessions = Array.from(sessionMap.values())
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
res.json({
success: true,
sessions
});
} catch (error) {
console.error('Error listing project sessions:', error);
res.status(500).json({ error: 'Failed to list sessions' });
}
});
// POST /api/projects/:id/sessions - Create new session in project
app.post('/api/projects/:id/sessions', requireAuth, async (req, res) => {
try {
const { id } = req.params;
const { metadata = {} } = req.body;
// Validate ID
const validatedId = validateProjectId(id);
if (!validatedId) {
return res.status(400).json({ error: 'Invalid project ID' });
}
// Check if project exists
const project = db.prepare(`
SELECT id, name, path FROM projects
WHERE id = ? AND deletedAt IS NULL
`).get(validatedId);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
// Create session using Claude service
const session = await claudeService.createSession(project.path, {
...metadata,
project: project.name,
projectId: validatedId
});
// Update project lastActivity
db.prepare(`
UPDATE projects
SET lastActivity = ?
WHERE id = ?
`).run(new Date().toISOString(), validatedId);
res.json({
success: true,
session: {
id: session.id,
title: session.title || session.metadata?.project || 'New Session',
agent: session.metadata?.agent || 'claude',
createdAt: session.createdAt,
updatedAt: session.lastActivity || session.createdAt,
metadata: session.metadata
}
});
} catch (error) {
console.error('Error creating session:', error);
res.status(500).json({ error: 'Failed to create session' });
}
});
// GET /api/recycle-bin - List deleted items
app.get('/api/recycle-bin', requireAuth, (req, res) => {
try {
@@ -1987,6 +2293,71 @@ wss.on('connection', (ws, req) => {
error: error.message
}));
}
} else if (data.type === 'approval-request') {
// AI agent requesting approval for a command
const { sessionId, command, explanation } = data;
console.log(`[WebSocket] Approval request for session ${sessionId}: ${command}`);
// Create pending approval
const approvalId = approvalManager.createApproval(sessionId, command, explanation);
// Forward approval request to all clients subscribed to this session
clients.forEach((client) => {
if (client.sessionId === sessionId && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify({
type: 'approval-request',
id: approvalId,
sessionId,
command,
explanation
}));
}
});
} else if (data.type === 'approval-response') {
// User responded to approval request
const { id, approved, customCommand } = data;
console.log(`[WebSocket] Approval response for ${id}: approved=${approved}`);
// Get the approval
const approval = approvalManager.getApproval(id);
if (!approval) {
console.error(`[WebSocket] Approval ${id} not found or expired`);
ws.send(JSON.stringify({
type: 'approval-expired',
id
}));
return;
}
// Remove from pending
approvalManager.removeApproval(id);
// Send approval as a message to the AI agent
const approvalMessage = approved
? (customCommand
? `[USER APPROVED WITH MODIFICATION: ${customCommand}]`
: `[USER APPROVED: ${approval.command}]`)
: `[USER REJECTED: ${approval.command}]`;
// Add to session context
claudeService.sendCommand(approval.sessionId, approvalMessage);
// Forward response confirmation to clients
clients.forEach((client) => {
if (client.sessionId === approval.sessionId && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify({
type: 'approval-confirmed',
id,
approved,
customCommand: customCommand || null
}));
}
});
} else if (data.type === 'subscribe') {
// Subscribe to a specific session's output
const { sessionId } = data;
@@ -2105,6 +2476,50 @@ claudeService.on('operations-error', (data) => {
});
});
// Forward approval-request event
claudeService.on('approval-request', (data) => {
console.log(`Approval request for session ${data.sessionId}:`, data.command);
// Create pending approval
const approvalId = approvalManager.createApproval(data.sessionId, data.command, data.explanation);
// Forward to all clients subscribed to this session
clients.forEach((client, clientId) => {
if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) {
try {
client.ws.send(JSON.stringify({
type: 'approval-request',
id: approvalId,
sessionId: data.sessionId,
command: data.command,
explanation: data.explanation
}));
} catch (error) {
console.error(`Error sending approval request to client ${clientId}:`, error);
}
}
});
});
// Forward approval-expired event
claudeService.on('approval-expired', (data) => {
console.log(`Approval expired for session ${data.sessionId}:`, data.id);
// Forward to all clients subscribed to this session
clients.forEach((client, clientId) => {
if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) {
try {
client.ws.send(JSON.stringify({
type: 'approval-expired',
id: data.id
}));
} catch (error) {
console.error(`Error sending approval-expired to client ${clientId}:`, error);
}
}
});
});
console.log(`WebSocket server running on ws://localhost:${PORT}/claude/api/claude/chat`);
// Real-time error monitoring endpoint
@@ -2128,6 +2543,13 @@ app.post('/claude/api/log-error', express.json(), (req, res) => {
const errorEntry = JSON.stringify({ ...error, loggedAt: timestamp }) + '\n';
fs.appendFileSync(errorLogPath, errorEntry);
// Store in recent errors array for debug endpoint
recentErrors.push({ ...error, loggedAt: timestamp });
// Keep only last 100 errors
if (recentErrors.length > 100) {
recentErrors.shift();
}
// 🤖 AUTO-TRIGGER: Spawn auto-fix agent in background
const { spawn } = require('child_process');
const agentPath = '/home/uroma/obsidian-web-interface/scripts/auto-fix-agent.js';
@@ -2157,6 +2579,284 @@ app.post('/claude/api/log-error', express.json(), (req, res) => {
res.json({ received: true, autoFixTriggered: true });
});
// ============================================================
// FILESYSTEM API - Folder Explorer
// ============================================================
/**
* Expand ~ to home directory and validate path
*/
function expandPath(userPath) {
const homeDir = os.homedir();
// Expand ~ or ~user
let expanded = userPath.replace(/^~\/?/, homeDir + '/');
// Resolve to absolute path
expanded = path.resolve(expanded);
// Security: Must be under home directory
if (!expanded.startsWith(homeDir)) {
throw new Error('Access denied: Path outside home directory');
}
return expanded;
}
/**
* Get directory contents for folder tree
*/
app.get('/api/filesystem/list', requireAuth, async (req, res) => {
try {
const { path: userPath = '~' } = req.query;
// Expand and validate path
const expandedPath = expandPath(userPath);
// Check if path exists
try {
await fs.promises.access(expandedPath, fs.constants.R_OK);
} catch {
return res.json({
success: false,
error: 'Directory not found or inaccessible',
path: expandedPath
});
}
// Read directory
const entries = await fs.promises.readdir(expandedPath, { withFileTypes: true });
// Filter directories only, sort, and get metadata
const items = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const fullPath = path.join(expandedPath, entry.name);
// Skip hidden directories (except .git)
if (entry.name.startsWith('.') && entry.name !== '.git') {
continue;
}
try {
const subEntries = await fs.promises.readdir(fullPath, { withFileTypes: true });
const subDirs = subEntries.filter(e => e.isDirectory()).length;
items.push({
name: entry.name,
type: 'directory',
path: fullPath,
children: subDirs,
hasChildren: subDirs > 0
});
} catch {
// Can't read subdirectory (permissions)
items.push({
name: entry.name,
type: 'directory',
path: fullPath,
children: 0,
hasChildren: false,
locked: true
});
}
}
}
// Sort alphabetically
items.sort((a, b) => a.name.localeCompare(b.name));
res.json({
success: true,
path: expandedPath,
displayPath: expandedPath.replace(os.homedir(), '~'),
items
});
} catch (error) {
console.error('Error listing directory:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* Validate if path exists and is accessible
*/
app.get('/api/filesystem/validate', requireAuth, async (req, res) => {
try {
const { path: userPath } = req.query;
if (!userPath) {
return res.status(400).json({
success: false,
error: 'Path parameter is required'
});
}
// Expand and validate path
const expandedPath = expandPath(userPath);
// Check if path exists
try {
await fs.promises.access(expandedPath, fs.constants.F_OK);
} catch {
return res.json({
success: true,
valid: false,
path: expandedPath,
displayPath: expandedPath.replace(os.homedir(), '~'),
exists: false
});
}
// Check read access
let readable = false;
try {
await fs.promises.access(expandedPath, fs.constants.R_OK);
readable = true;
} catch {}
// Check write access
let writable = false;
try {
await fs.promises.access(expandedPath, fs.constants.W_OK);
writable = true;
} catch {}
// Check if it's a directory
const stats = await fs.promises.stat(expandedPath);
const isDirectory = stats.isDirectory();
res.json({
success: true,
valid: true,
path: expandedPath,
displayPath: expandedPath.replace(os.homedir(), '~'),
exists: true,
readable,
writable,
isDirectory
});
} catch (error) {
// Path validation error
res.json({
success: true,
valid: false,
error: error.message,
path: userPath
});
}
});
/**
* Create new directory
*/
app.post('/api/filesystem/mkdir', requireAuth, async (req, res) => {
try {
const { path: userPath } = req.body;
if (!userPath) {
return res.status(400).json({
success: false,
error: 'Path parameter is required'
});
}
// Expand and validate path
const expandedPath = expandPath(userPath);
// Validate folder name (no special characters)
const folderName = path.basename(expandedPath);
if (!/^[a-zA-Z0-9._-]+$/.test(folderName)) {
return res.status(400).json({
success: false,
error: 'Invalid folder name. Use only letters, numbers, dots, dashes, and underscores.'
});
}
// Check if already exists
try {
await fs.access(expandedPath, fs.constants.F_OK);
return res.status(400).json({
success: false,
error: 'Directory already exists'
});
} catch {
// Doesn't exist, good to create
}
// Create directory
await fs.promises.mkdir(expandedPath, { mode: 0o755 });
res.json({
success: true,
path: expandedPath,
displayPath: expandedPath.replace(os.homedir(), '~')
});
} catch (error) {
console.error('Error creating directory:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* Get quick access locations
*/
app.get('/api/filesystem/quick-access', requireAuth, async (req, res) => {
try {
const homeDir = os.homedir();
const locations = [
{ path: '~', name: 'Home', icon: '🏠' },
{ path: '~/projects', name: 'Projects', icon: '📂' },
{ path: '~/code', name: 'Code', icon: '💻' },
{ path: '~/Documents', name: 'Documents', icon: '📄' },
{ path: '~/Downloads', name: 'Downloads', icon: '📥' }
];
// Check which locations exist and get item counts
const enrichedLocations = await Promise.all(locations.map(async (loc) => {
try {
const expandedPath = expandPath(loc.path);
await fs.promises.access(expandedPath, fs.constants.R_OK);
// Get item count
const entries = await fs.promises.readdir(expandedPath, { withFileTypes: true });
const count = entries.filter(e => e.isDirectory()).length;
return {
...loc,
path: expandedPath,
displayPath: expandedPath.replace(homeDir, '~'),
exists: true,
count
};
} catch {
return {
...loc,
exists: false,
count: 0
};
}
}));
res.json({
success: true,
locations: enrichedLocations
});
} catch (error) {
console.error('Error getting quick access locations:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down gracefully...');