const express = require('express'); 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'); const ClaudeCodeService = require('./services/claude-service'); 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, typographer: true, highlight: function (str, lang) { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(str, { language: lang }).value; } catch (__) {} } return ''; } }); // Configuration const VAULT_PATH = '/home/uroma/obsidian-vault'; const PORT = 3010; const SESSION_SECRET = 'obsidian-web-secret-' + Math.random().toString(36).substring(2); // Users database (in production, use a real database) const users = { admin: { passwordHash: bcrypt.hashSync('!@#$q1w2e3r4!A', 10) } }; // 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(); }, 60 * 60 * 1000); // Trust proxy for proper session handling behind nginx app.set('trust proxy', 1); // Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: false, // Will work with both HTTP and HTTPS behind proxy sameSite: 'lax', httpOnly: true, maxAge: 24 * 60 * 60 * 1000 // 24 hours }, name: 'connect.sid' })); // Authentication middleware function requireAuth(req, res, next) { if (req.session.userId) { next(); } else { res.status(401).json({ error: 'Unauthorized' }); } } // Routes // Project URL route - decode base64 path and serve file app.get('/p/:base64Path/', (req, res) => { try { const base64Path = req.params.base64Path; // Decode base64 path let decodedPath; try { decodedPath = Buffer.from(base64Path, 'base64').toString('utf-8'); } catch (error) { console.error('Error decoding base64 path:', error); return res.status(400).send('Invalid base64 path'); } // Resolve the full path const fullPath = path.resolve(decodedPath); // Security check: ensure path is within allowed directories // Allow access to home directory and obsidian vault const allowedPaths = [ '/home/uroma', VAULT_PATH ]; const isAllowed = allowedPaths.some(allowedPath => { return fullPath.startsWith(allowedPath); }); if (!isAllowed) { console.error('Path outside allowed directories:', fullPath); return res.status(403).send('Access denied'); } // Check if file exists if (!fs.existsSync(fullPath)) { console.error('File not found:', fullPath); return res.status(404).send('File not found'); } // Check if it's a file (not a directory) const stats = fs.statSync(fullPath); if (stats.isDirectory()) { // If it's a directory, try to serve index.html or list files const indexPath = path.join(fullPath, 'index.html'); if (fs.existsSync(indexPath)) { return res.sendFile(indexPath); } else { return res.status(403).send('Directory access not allowed'); } } // Determine content type const ext = path.extname(fullPath).toLowerCase(); const contentTypes = { '.html': 'text/html', '.htm': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.txt': 'text/plain', '.md': 'text/markdown' }; const contentType = contentTypes[ext] || 'application/octet-stream'; // Serve the file res.setHeader('Content-Type', contentType); res.sendFile(fullPath); } catch (error) { console.error('Error serving file:', error); res.status(500).send('Internal server error'); } }); // Sessions landing page (root of /claude/) app.get('/claude/', (req, res) => { if (req.session.userId) { // Authenticated - show landing page res.sendFile(path.join(__dirname, 'public', 'claude-landing.html')); } else { // Not authenticated - show login page res.sendFile(path.join(__dirname, 'public', 'index.html')); } }); // IDE routes app.get('/claude/ide', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'claude-ide', 'index.html')); }); app.get('/claude/ide/*', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'claude-ide', 'index.html')); }); // Projects page route app.get('/projects', (req, res) => { if (req.session.userId) { // Authenticated - serve projects page res.sendFile(path.join(__dirname, 'public', 'projects.html')); } else { // Not authenticated - redirect to login res.redirect('/claude/'); } }); // Serve static files (must come after specific routes) app.use('/claude', express.static(path.join(__dirname, 'public'))); // Serve node_modules for CodeMirror 6 import map app.use('/claude/node_modules', express.static(path.join(__dirname, 'node_modules'))); // Login route app.post('/claude/api/login', (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } const user = users[username]; if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); } const isValid = bcrypt.compareSync(password, user.passwordHash); if (!isValid) { return res.status(401).json({ error: 'Invalid credentials' }); } req.session.userId = username; req.session.username = username; res.json({ success: true, username }); }); // Logout route app.post('/claude/api/logout', (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ error: 'Logout failed' }); } res.json({ success: true }); }); }); // Check auth status app.get('/claude/api/auth/status', (req, res) => { if (req.session.userId) { res.json({ authenticated: true, username: req.session.username }); } else { res.json({ authenticated: false }); } }); // Get file tree app.get('/claude/api/files', requireAuth, (req, res) => { try { const getFileTree = (dir, relativePath = '') => { const items = []; const files = fs.readdirSync(dir); files.forEach(file => { if (file === '.obsidian') return; // Skip Obsidian config const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); const relPath = path.join(relativePath, file); if (stat.isDirectory()) { items.push({ name: file, type: 'folder', path: relPath, children: getFileTree(fullPath, relPath) }); } else if (file.endsWith('.md')) { items.push({ name: file, type: 'file', path: relPath }); } }); return items.sort((a, b) => { if (a.type === b.type) return a.name.localeCompare(b.name); return a.type === 'folder' ? -1 : 1; }); }; const tree = getFileTree(VAULT_PATH); res.json({ tree }); } catch (error) { console.error('Error reading files:', error); res.status(500).json({ error: 'Failed to read files' }); } }); // Get file content app.get('/claude/api/file/*', requireAuth, (req, res) => { try { const filePath = req.path.replace('/claude/api/file/', ''); const fullPath = path.join(VAULT_PATH, filePath); // Security check if (!fullPath.startsWith(VAULT_PATH)) { return res.status(403).json({ error: 'Access denied' }); } if (!fs.existsSync(fullPath)) { return res.status(404).json({ error: 'File not found' }); } const content = fs.readFileSync(fullPath, 'utf-8'); const stats = fs.statSync(fullPath); // Check if file is HTML const isHtmlFile = filePath.toLowerCase().endsWith('.html') || filePath.toLowerCase().endsWith('.htm'); if (isHtmlFile) { // Return raw HTML content without processing res.json({ path: filePath, content: content, // Raw content html: `
${content.replace(//g, '>')}
`, frontmatter: {}, modified: stats.mtime, created: stats.birthtime }); return; } // Parse frontmatter for non-HTML files let frontmatter = {}; let markdownContent = content; const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); if (frontmatterMatch) { try { frontmatterMatch[1].split('\n').forEach(line => { const [key, ...valueParts] = line.split(':'); if (key && valueParts.length > 0) { frontmatter[key.trim()] = valueParts.join(':').trim(); } }); } catch (e) { console.error('Error parsing frontmatter:', e); } markdownContent = content.replace(/^---\n[\s\S]*?\n---\n?/, ''); } // Convert markdown to HTML const htmlContent = md.render(markdownContent); res.json({ path: filePath, content: markdownContent, html: htmlContent, frontmatter, modified: stats.mtime, created: stats.birthtime }); } catch (error) { console.error('Error reading file:', error); res.status(500).json({ error: 'Failed to read file' }); } }); // Save file content app.put('/claude/api/file/*', requireAuth, (req, res) => { try { const filePath = req.path.replace('/claude/api/file/', ''); const fullPath = path.join(VAULT_PATH, filePath); const { content } = req.body; // Security check if (!fullPath.startsWith(VAULT_PATH)) { return res.status(403).json({ error: 'Access denied' }); } // Create directory if it doesn't exist const dir = path.dirname(fullPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(fullPath, content, 'utf-8'); res.json({ success: true }); } catch (error) { console.error('Error saving file:', error); res.status(500).json({ error: 'Failed to save file' }); } }); // Create new file app.post('/claude/api/file', requireAuth, (req, res) => { try { const { path: filePath, content = '' } = req.body; const fullPath = path.join(VAULT_PATH, filePath); // Security check if (!fullPath.startsWith(VAULT_PATH)) { return res.status(403).json({ error: 'Access denied' }); } // Create directory if it doesn't exist const dir = path.dirname(fullPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } if (fs.existsSync(fullPath)) { return res.status(409).json({ error: 'File already exists' }); } fs.writeFileSync(fullPath, content, 'utf-8'); res.json({ success: true, path: filePath }); } catch (error) { console.error('Error creating file:', error); res.status(500).json({ error: 'Failed to create file' }); } }); // Search files app.get('/claude/api/search', requireAuth, (req, res) => { try { const { q } = req.query; if (!q) { return res.json({ results: [] }); } const searchInDir = (dir, relativePath = '') => { const results = []; const files = fs.readdirSync(dir); files.forEach(file => { if (file === '.obsidian') return; const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); const relPath = path.join(relativePath, file); if (stat.isDirectory()) { results.push(...searchInDir(fullPath, relPath)); } else if (file.endsWith('.md')) { const content = fs.readFileSync(fullPath, 'utf-8'); const lowerContent = content.toLowerCase(); const lowerQ = q.toLowerCase(); if (lowerContent.includes(lowerQ) || file.toLowerCase().includes(lowerQ)) { // Extract a preview const lines = content.split('\n'); const previewLines = []; for (const line of lines) { if (line.toLowerCase().includes(lowerQ)) { previewLines.push(line.trim()); if (previewLines.length >= 3) break; } } results.push({ path: relPath, name: file, preview: previewLines.join('...').substring(0, 200) }); } } }); return results; }; const results = searchInDir(VAULT_PATH); res.json({ results }); } catch (error) { console.error('Error searching:', error); res.status(500).json({ error: 'Search failed' }); } }); // Get recent files app.get('/claude/api/recent', requireAuth, (req, res) => { try { const limit = parseInt(req.query.limit) || 10; const files = []; const getFilesRecursively = (dir, relativePath = '') => { const items = fs.readdirSync(dir); items.forEach(file => { if (file === '.obsidian') return; const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); const relPath = path.join(relativePath, file); if (stat.isDirectory()) { getFilesRecursively(fullPath, relPath); } else if (file.endsWith('.md')) { files.push({ path: relPath, name: file, modified: stat.mtime }); } }); }; getFilesRecursively(VAULT_PATH); files.sort((a, b) => b.modified - a.modified); res.json({ files: files.slice(0, limit) }); } catch (error) { console.error('Error getting recent files:', error); res.status(500).json({ error: 'Failed to get recent files' }); } }); // ============================================ // Claude Code IDE API Routes // ============================================ // Session Management // GET /claude/api/claude/sessions?project=/encoded/path app.get('/claude/api/claude/sessions', requireAuth, (req, res) => { try { const { project } = req.query; let activeSessions = claudeService.listSessions(); let historicalSessions = claudeService.loadHistoricalSessions(); // PROJECT FILTERING if (project) { const projectPath = decodeURIComponent(project); console.log('[SESSIONS] Filtering by project path:', projectPath); activeSessions = activeSessions.filter(s => { const sessionPath = s.workingDir || ''; return sessionPath.startsWith(projectPath) || sessionPath === projectPath; }); historicalSessions = historicalSessions.filter(s => { const sessionPath = s.workingDir || ''; return sessionPath.startsWith(projectPath) || sessionPath === projectPath; }); console.log('[SESSIONS] Filtered to', activeSessions.length, 'active,', historicalSessions.length, 'historical'); } res.json({ active: activeSessions, historical: historicalSessions }); } catch (error) { console.error('[SESSIONS] Error:', error); res.status(500).json({ error: 'Failed to list sessions' }); } }); app.post('/claude/api/claude/sessions', requireAuth, (req, res) => { try { const { workingDir, metadata, projectId } = req.body; // Validate projectId if provided let validatedProjectId = null; if (projectId !== null && projectId !== undefined) { validatedProjectId = validateProjectId(projectId); if (!validatedProjectId) { return res.status(400).json({ error: 'Invalid project ID' }); } // Verify project exists and is not deleted const project = db.prepare(` SELECT id FROM projects WHERE id = ? AND deletedAt IS NULL `).get(validatedProjectId); if (!project) { return res.status(404).json({ error: 'Project not found' }); } } // ===== Validate and create working directory ===== let validatedWorkingDir = workingDir || VAULT_PATH; // Resolve to absolute path const path = require('path'); const fs = require('fs'); const resolvedPath = path.resolve(validatedWorkingDir); // Security check: ensure path is within allowed boundaries const allowedPaths = [ VAULT_PATH, process.env.HOME || '/home/uroma', '/home/uroma' ]; const isAllowed = allowedPaths.some(allowedPath => { return resolvedPath.startsWith(allowedPath); }); if (!isAllowed) { console.error('[SESSIONS] Working directory outside allowed paths:', resolvedPath); return res.status(403).json({ error: 'Working directory outside allowed paths' }); } // Create directory if it doesn't exist if (!fs.existsSync(resolvedPath)) { console.log('[SESSIONS] Creating working directory:', resolvedPath); try { fs.mkdirSync(resolvedPath, { recursive: true }); } catch (mkdirError) { console.error('[SESSIONS] Failed to create directory:', mkdirError); return res.status(400).json({ error: `Failed to create working directory: ${mkdirError.message}` }); } } // Verify it's actually a directory try { const stats = fs.statSync(resolvedPath); if (!stats.isDirectory()) { return res.status(400).json({ error: 'Working directory is not a directory' }); } } catch (statError) { console.error('[SESSIONS] Failed to stat directory:', statError); return res.status(500).json({ error: 'Failed to validate working directory' }); } console.log('[SESSIONS] Using working directory:', resolvedPath); // ===== END directory validation ===== // Create session with validated path const sessionMetadata = { ...metadata, ...(validatedProjectId ? { projectId: validatedProjectId } : {}) }; const session = claudeService.createSession({ workingDir: resolvedPath, metadata: sessionMetadata }); // Store session in database with projectId db.prepare(` INSERT INTO sessions (id, projectId, deletedAt) VALUES (?, ?, NULL) `).run(session.id, validatedProjectId); // Update project's lastActivity if session was assigned to a project if (validatedProjectId) { const now = new Date().toISOString(); db.prepare(` UPDATE projects SET lastActivity = ? WHERE id = ? `).run(now, validatedProjectId); } res.json({ success: true, session: { id: session.id, pid: session.pid, workingDir: session.workingDir, status: session.status, createdAt: session.createdAt, projectId: validatedProjectId } }); } catch (error) { console.error('Error creating session:', error); res.status(500).json({ error: 'Failed to create session' }); } }); app.get('/claude/api/claude/sessions/:id', requireAuth, (req, res) => { try { const session = claudeService.getSession(req.params.id); res.json({ session }); } catch (error) { res.status(404).json({ error: error.message }); } }); app.post('/claude/api/claude/sessions/:id/command', requireAuth, (req, res) => { try { const { command } = req.body; const result = claudeService.sendCommand(req.params.id, command); res.json(result); } catch (error) { res.status(400).json({ error: error.message }); } }); // Context Management app.get('/claude/api/claude/sessions/:id/context', requireAuth, (req, res) => { try { const stats = claudeService.getContextStats(req.params.id); res.json({ stats }); } catch (error) { res.status(404).json({ error: error.message }); } }); // Operations Management - Preview operations before executing app.post('/claude/api/claude/sessions/:id/operations/preview', requireAuth, async (req, res) => { try { const { response } = req.body; const preview = await claudeService.previewOperations(req.params.id, response); res.json({ preview }); } catch (error) { console.error('Error previewing operations:', error); res.status(400).json({ error: error.message }); } }); // Operations Management - Execute approved operations app.post('/claude/api/claude/sessions/:id/operations/execute', requireAuth, async (req, res) => { try { const { response } = req.body; // Set up progress callback const onProgress = (progress) => { // Emit progress via WebSocket to all subscribed clients wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify({ type: 'operation-progress', sessionId: req.params.id, progress })); } }); }; const results = await claudeService.executeOperations(req.params.id, response, onProgress); res.json({ results }); } catch (error) { console.error('Error executing operations:', error); res.status(400).json({ error: error.message }); } }); // Update session metadata app.patch('/claude/api/claude/sessions/:id', requireAuth, async (req, res) => { try { const { metadata } = req.body; const sessionId = req.params.id; // Get session let session = claudeService.sessions.get(sessionId); if (!session) { // Try to load from historical sessions const historicalSessions = claudeService.loadHistoricalSessions(); const historical = historicalSessions.find(s => s.id === sessionId); if (!historical) { return res.status(404).json({ error: 'Session not found' }); } // For historical sessions, we can't update metadata directly // Return error for now return res.status(400).json({ error: 'Cannot update historical session metadata' }); } // Update metadata if (metadata && typeof metadata === 'object') { session.metadata = { ...session.metadata, ...metadata }; } res.json({ success: true, session: { id: session.id, metadata: session.metadata, workingDir: session.workingDir } }); } catch (error) { console.error('Error updating session:', error); res.status(500).json({ error: error.message }); } }); // Duplicate session app.post('/claude/api/claude/sessions/:id/duplicate', requireAuth, (req, res) => { try { const sessionId = req.params.id; // Get source session let sourceSession = claudeService.sessions.get(sessionId); if (!sourceSession) { return res.status(404).json({ error: 'Source session not found' }); } // Security check: validate workingDir is within VAULT_PATH const fullPath = path.resolve(sourceSession.workingDir); if (!fullPath.startsWith(VAULT_PATH)) { return res.status(400).json({ error: 'Invalid working directory' }); } // Get projectId from source session (if exists) const sourceProjectId = sourceSession.metadata.projectId || null; // Create new session with same settings const newSession = claudeService.createSession({ workingDir: sourceSession.workingDir, metadata: { ...sourceSession.metadata, duplicatedFrom: sessionId, source: 'web-ide' } }); // Store duplicated session in database with same projectId as source db.prepare(` INSERT INTO sessions (id, projectId, deletedAt) VALUES (?, ?, NULL) `).run(newSession.id, sourceProjectId); // Update project's lastActivity if session was assigned to a project if (sourceProjectId) { const now = new Date().toISOString(); db.prepare(` UPDATE projects SET lastActivity = ? WHERE id = ? `).run(now, sourceProjectId); } res.json({ success: true, session: { id: newSession.id, pid: newSession.pid, workingDir: newSession.workingDir, status: newSession.status, createdAt: newSession.createdAt, metadata: newSession.metadata, projectId: sourceProjectId } }); } catch (error) { console.error('Error duplicating session:', error); res.status(500).json({ error: 'Failed to duplicate session' }); } }); // Fork session from a specific message index app.post('/claude/api/claude/sessions/:id/fork', requireAuth, (req, res) => { try { const sessionId = req.params.id; const messageIndex = parseInt(req.query.messageIndex) || -1; // -1 means all messages // Get source session let sourceSession = claudeService.sessions.get(sessionId); if (!sourceSession) { return res.status(404).json({ error: 'Source session not found' }); } // Security check: validate workingDir is within VAULT_PATH const fullPath = path.resolve(sourceSession.workingDir); if (!fullPath.startsWith(VAULT_PATH)) { return res.status(400).json({ error: 'Invalid working directory' }); } // Get messages to fork (1..messageIndex) let messagesToFork = []; if (sourceSession.outputBuffer && sourceSession.outputBuffer.length > 0) { if (messageIndex === -1) { // Fork all messages messagesToFork = [...sourceSession.outputBuffer]; } else { // Fork messages up to messageIndex messagesToFork = sourceSession.outputBuffer.slice(0, messageIndex + 1); } } // Get projectId from source session (if exists) const sourceProjectId = sourceSession.metadata.projectId || null; // Create new session with forked context const newSession = claudeService.createSession({ workingDir: sourceSession.workingDir, metadata: { ...sourceSession.metadata, forkedFrom: sessionId, forkedAtMessageIndex: messageIndex, forkedAt: new Date().toISOString(), source: 'web-ide' } }); // Initialize output buffer with forked messages if (messagesToFork.length > 0) { newSession.outputBuffer = messagesToFork; } // Store forked session in database with same projectId as source db.prepare(` INSERT INTO sessions (id, projectId, deletedAt) VALUES (?, ?, NULL) `).run(newSession.id, sourceProjectId); // Update project's lastActivity if session was assigned to a project if (sourceProjectId) { const now = new Date().toISOString(); db.prepare(` UPDATE projects SET lastActivity = ? WHERE id = ? `).run(now, sourceProjectId); } console.log(`[FORK] Forked session ${sessionId} at message ${messageIndex} -> ${newSession.id}`); console.log(`[FORK] Copied ${messagesToFork.length} messages`); res.json({ success: true, session: { id: newSession.id, pid: newSession.pid, workingDir: newSession.workingDir, status: newSession.status, createdAt: newSession.createdAt, metadata: newSession.metadata, projectId: sourceProjectId, messageCount: messagesToFork.length } }); } catch (error) { console.error('Error forking session:', error); res.status(500).json({ error: 'Failed to fork session' }); } }); // Delete session app.delete('/claude/api/claude/sessions/:id', requireAuth, (req, res) => { try { const sessionId = req.params.id; // Check if session exists const session = claudeService.sessions.get(sessionId); if (!session) { return res.status(404).json({ error: 'Session not found' }); } // Kill the claude process if running using service method if (session.status === 'running' && session.process) { try { claudeService.terminateSession(sessionId); } catch (error) { // If termination fails, log but continue with deletion console.error(`Error terminating session ${sessionId}:`, error.message); } } // Remove from sessions map claudeService.sessions.delete(sessionId); // Delete session file if exists const sessionFile = path.join(claudeService.claudeSessionsDir, `${sessionId}.md`); if (fs.existsSync(sessionFile)) { fs.unlinkSync(sessionFile); } res.json({ success: true }); } catch (error) { console.error('Error deleting session:', error); res.status(500).json({ error: 'Failed to delete session' }); } }); // Move session to different project app.post('/claude/api/claude/sessions/:id/move', requireAuth, (req, res) => { try { const { id } = req.params; const { projectId } = req.body; // Check if session exists (in-memory or historical) - fetch once let session = claudeService.sessions.get(id); let isActiveSession = !!session; if (!session) { // Check historical sessions const historicalSessions = claudeService.loadHistoricalSessions(); session = historicalSessions.find(s => s.id === id); } if (!session) { return res.status(404).json({ error: 'Session not found' }); } // If projectId is provided, validate it exists and is not deleted if (projectId !== null && projectId !== undefined) { const validatedId = validateProjectId(projectId); if (!validatedId) { return res.status(400).json({ error: 'Invalid project ID' }); } const project = db.prepare(` SELECT id FROM projects WHERE id = ? AND deletedAt IS NULL `).get(validatedId); if (!project) { return res.status(404).json({ error: 'Project not found' }); } // Update session's metadata with projectId if (isActiveSession) { // Active session - update metadata and persist to database session.metadata.projectId = validatedId; db.prepare(` INSERT OR REPLACE INTO sessions (id, projectId, deletedAt) VALUES (?, ?, NULL) `).run(id, validatedId); } else { // Historical session - update in database db.prepare(` INSERT OR REPLACE INTO sessions (id, projectId, deletedAt) VALUES (?, ?, NULL) `).run(id, validatedId); } } else { // Move to unassigned (projectId = null) if (isActiveSession) { // Active session - remove projectId from metadata and persist to database delete session.metadata.projectId; db.prepare(` INSERT OR REPLACE INTO sessions (id, projectId, deletedAt) VALUES (?, NULL, NULL) `).run(id); } else { // Historical session - update in database db.prepare(` INSERT OR REPLACE INTO sessions (id, projectId, deletedAt) VALUES (?, NULL, NULL) `).run(id); } } res.json({ success: true }); } catch (error) { console.error('Error moving session:', error); res.status(500).json({ error: 'Failed to move session' }); } }); // Preview Management - Start preview server app.post('/claude/api/claude/sessions/:id/preview/start', requireAuth, async (req, res) => { try { const { workingDir } = req.body; // For now, return a simple response // In a full implementation, this would integrate with the workflow service // to detect project type and start appropriate dev server res.json({ success: true, url: null, port: null, processId: null, message: 'Preview functionality requires workflow service integration' }); } catch (error) { console.error('Error starting preview:', error); res.status(400).json({ error: error.message }); } }); // Preview Management - Stop preview server app.post('/claude/api/claude/sessions/:id/preview/stop', requireAuth, async (req, res) => { try { // For now, return a simple response // In a full implementation, this would stop the preview server res.json({ success: true, message: 'Preview stopped' }); } catch (error) { console.error('Error stopping preview:', error); res.status(400).json({ error: error.message }); } }); // Project Management app.get('/claude/api/claude/projects', requireAuth, (req, res) => { try { const projectsDir = path.join(VAULT_PATH, 'Claude Projects'); let projects = []; if (fs.existsSync(projectsDir)) { const files = fs.readdirSync(projectsDir); const projectFiles = files.filter(f => f.endsWith('.md') && !f.includes('active-projects') && !f.includes('templates') ); projects = projectFiles.map(file => { const filepath = path.join(projectsDir, file); const stat = fs.statSync(filepath); return { name: file.replace('.md', ''), path: `Claude Projects/${file}`, modified: stat.mtime }; }); } res.json({ projects }); } catch (error) { console.error('Error listing projects:', error); res.status(500).json({ error: 'Failed to list projects' }); } }); app.post('/claude/api/claude/projects', requireAuth, (req, res) => { try { const { name, description, type } = req.body; const projectsDir = path.join(VAULT_PATH, 'Claude Projects'); if (!fs.existsSync(projectsDir)) { fs.mkdirSync(projectsDir, { recursive: true }); } const filename = `${name}.md`; const filepath = path.join(projectsDir, filename); if (fs.existsSync(filepath)) { return res.status(409).json({ error: 'Project already exists' }); } const content = `--- type: project name: ${name} created: ${new Date().toISOString()} status: planning --- # ${name} ${description || ''} ## Planning \`\`\`tasks path includes ${name} not done sort by created \`\`\` ## Tasks ## Notes --- Created via Claude Code Web IDE `; fs.writeFileSync(filepath, content, 'utf-8'); res.json({ success: true, project: { name, path: `Claude Projects/${filename}` } }); } catch (error) { console.error('Error creating project:', error); res.status(500).json({ error: 'Failed to create project' }); } }); // ============================================ // Project CRUD API Endpoints (SQLite) // ============================================ // Helper function to validate project ID function validateProjectId(id) { const idNum = parseInt(id, 10); if (isNaN(idNum) || idNum <= 0 || !Number.isInteger(idNum)) { return null; } return idNum; } // Helper function to validate project path is within allowed scope function validateProjectPath(projectPath) { // Resolve the absolute path const resolvedPath = path.resolve(projectPath); // For now, we'll allow any absolute path // In production, you might want to restrict to specific directories // Check if it's an absolute path if (!path.isAbsolute(resolvedPath)) { return { valid: false, error: 'Path must be absolute' }; } // Check for path traversal attempts const normalizedPath = path.normalize(projectPath); if (normalizedPath !== projectPath && !projectPath.startsWith('..')) { // Path contained relative components that were normalized return { valid: false, error: 'Path contains invalid components' }; } 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 { const projects = db.prepare(` SELECT id, name, description, icon, color, path, createdAt, lastActivity FROM projects WHERE deletedAt IS NULL ORDER BY lastActivity DESC `).all(); // 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, projects: projectsWithSessionCount }); } catch (error) { console.error('Error listing projects:', error); res.status(500).json({ error: 'Failed to list projects' }); } }); // POST /api/projects - Create new project app.post('/api/projects', requireAuth, (req, res) => { try { const { name, path: projectPath, description, icon, color } = req.body; // Validate required fields if (!name || !projectPath) { return res.status(400).json({ error: 'Name and path are required' }); } // Validate path const pathValidation = validateProjectPath(projectPath); if (!pathValidation.valid) { return res.status(400).json({ error: pathValidation.error }); } // Check for duplicate names (only among non-deleted projects) const existing = db.prepare(` SELECT id FROM projects WHERE name = ? AND deletedAt IS NULL `).get(name); if (existing) { return res.status(409).json({ error: 'Project with this name already exists' }); } const now = new Date().toISOString(); // Set defaults const projectIcon = icon || 'šŸ“'; const projectColor = color || '#4a9eff'; const result = db.prepare(` INSERT INTO projects (name, description, icon, color, path, createdAt, lastActivity) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(name, description || null, projectIcon, projectColor, pathValidation.path, now, now); // Get the inserted project const project = db.prepare(` SELECT id, name, description, icon, color, path, createdAt, lastActivity FROM projects WHERE id = ? `).get(result.lastInsertRowid); res.status(201).json({ success: true, project: { ...project, sessionCount: 0 } }); } catch (error) { console.error('Error creating project:', error); res.status(500).json({ error: 'Failed to create project' }); } }); // PUT /api/projects/:id - Update project app.put('/api/projects/:id', requireAuth, (req, res) => { try { const { id } = req.params; const { name, description, icon, color, path: projectPath } = req.body; // Validate ID const validatedId = validateProjectId(id); if (!validatedId) { return res.status(400).json({ error: 'Invalid project ID' }); } // Validate path if provided if (projectPath !== undefined) { const pathValidation = validateProjectPath(projectPath); if (!pathValidation.valid) { return res.status(400).json({ error: pathValidation.error }); } } // Check if project exists and is not deleted const existing = db.prepare(` SELECT id FROM projects WHERE id = ? AND deletedAt IS NULL `).get(validatedId); if (!existing) { return res.status(404).json({ error: 'Project not found' }); } // If updating name, check for duplicates if (name) { const duplicate = db.prepare(` SELECT id FROM projects WHERE name = ? AND id != ? AND deletedAt IS NULL `).get(name, validatedId); if (duplicate) { return res.status(409).json({ error: 'Project with this name already exists' }); } } // Build update query dynamically based on provided fields const updates = []; const values = []; if (name !== undefined) { updates.push('name = ?'); values.push(name); } if (description !== undefined) { updates.push('description = ?'); values.push(description); } if (icon !== undefined) { updates.push('icon = ?'); values.push(icon); } if (color !== undefined) { updates.push('color = ?'); values.push(color); } if (projectPath !== undefined) { updates.push('path = ?'); const pathValidation = validateProjectPath(projectPath); values.push(pathValidation.path); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } values.push(validatedId); db.prepare(` UPDATE projects SET ${updates.join(', ')} WHERE id = ? AND deletedAt IS NULL `).run(...values); // Get the updated project const project = db.prepare(` SELECT id, name, description, icon, color, path, createdAt, lastActivity FROM projects WHERE id = ? `).get(validatedId); res.json({ success: true, project: { ...project, sessionCount: 0 } }); } catch (error) { console.error('Error updating project:', error); res.status(500).json({ error: 'Failed to update project' }); } }); // DELETE /api/projects/:id - Soft delete project app.delete('/api/projects/:id', 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 and is not already deleted const existing = db.prepare(` SELECT id FROM projects WHERE id = ? AND deletedAt IS NULL `).get(validatedId); if (!existing) { return res.status(404).json({ error: 'Project not found' }); } // Soft delete by setting deletedAt const now = new Date().toISOString(); db.prepare(` UPDATE projects SET deletedAt = ? WHERE id = ? `).run(now, validatedId); // Soft delete all sessions associated with this project db.prepare(` UPDATE sessions SET deletedAt = ? WHERE projectId = ? `).run(now, validatedId); // Also update in-memory sessions for (const [sessionId, session] of claudeService.sessions.entries()) { if (session.metadata.projectId === validatedId) { // Mark as deleted in metadata session.metadata.deletedAt = now; } } res.json({ success: true }); } catch (error) { console.error('Error soft deleting project:', error); res.status(500).json({ error: 'Failed to delete project' }); } }); // POST /api/projects/:id/restore - Restore project from recycle bin app.post('/api/projects/:id/restore', 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 and is in recycle bin const existing = db.prepare(` SELECT id FROM projects WHERE id = ? AND deletedAt IS NOT NULL `).get(validatedId); if (!existing) { return res.status(404).json({ error: 'Project not found in recycle bin' }); } // Restore by setting deletedAt to NULL db.prepare(` UPDATE projects SET deletedAt = NULL WHERE id = ? `).run(validatedId); res.json({ success: true }); } catch (error) { console.error('Error restoring project:', error); res.status(500).json({ error: 'Failed to restore project' }); } }); // DELETE /api/projects/:id/permanent - Permanently delete project app.delete('/api/projects/:id/permanent', 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 existing = db.prepare(` SELECT id FROM projects WHERE id = ? `).get(validatedId); if (!existing) { return res.status(404).json({ error: 'Project not found' }); } // Permanently delete the project db.prepare(` DELETE FROM projects WHERE id = ? `).run(validatedId); res.json({ success: true }); } catch (error) { console.error('Error permanently deleting project:', error); res.status(500).json({ error: 'Failed to permanently delete project' }); } }); // 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 { const items = db.prepare(` SELECT id, name, description, icon, color, path, deletedAt FROM projects WHERE deletedAt IS NOT NULL ORDER BY deletedAt DESC `).all(); // Add sessionCount (0 for now, will be implemented in Task 4) const itemsWithSessionCount = items.map(item => ({ ...item, sessionCount: 0 })); res.json({ success: true, items: itemsWithSessionCount }); } catch (error) { console.error('Error listing recycle bin:', error); res.status(500).json({ error: 'Failed to list recycle bin' }); } }); // GET /api/projects/suggestions - Get smart project suggestions for a session app.get('/api/projects/suggestions', requireAuth, (req, res) => { try { const { sessionId } = req.query; // Validate sessionId parameter if (!sessionId) { return res.status(400).json({ error: 'sessionId parameter is required' }); } // Get the session from in-memory or historical let session = claudeService.sessions.get(sessionId); if (!session) { // Try historical sessions const historicalSessions = claudeService.loadHistoricalSessions(); session = historicalSessions.find(s => s.id === sessionId); } if (!session) { return res.status(404).json({ error: 'Session not found' }); } // Get all active projects const projects = db.prepare(` SELECT id, name, icon, color, path, lastActivity FROM projects WHERE deletedAt IS NULL `).all(); const sessionWorkingDir = session.workingDir || ''; const now = new Date(); // Calculate scores for each project const scoredProjects = projects.map(project => { let score = 0; const reasons = []; // 1. Directory match: session workingDir === project path (90 points) if (sessionWorkingDir === project.path) { score += 90; reasons.push('Same directory'); } // 2. Subdirectory: session workingDir startsWith project path (50 points) // Only if not already an exact match else if (sessionWorkingDir.startsWith(project.path + '/')) { score += 50; reasons.push('Subdirectory'); } // 3. Recent use: project used today (<1 day ago) (20 points) if (project.lastActivity) { const lastActivityDate = new Date(project.lastActivity); const daysSinceActivity = Math.floor((now - lastActivityDate) / (1000 * 60 * 60 * 24)); if (daysSinceActivity < 1) { score += 20; reasons.push('Used today'); } // 4. Week-old use: project used this week (<7 days ago) (10 points) else if (daysSinceActivity < 7) { score += 10; reasons.push(`Used ${daysSinceActivity} days ago`); } } // 5. Name overlap: check if session workingDir contains project name or vice versa (15 points) const sessionDirName = path.basename(sessionWorkingDir).toLowerCase(); const projectNameLower = project.name.toLowerCase(); if (sessionDirName && projectNameLower && (sessionDirName.includes(projectNameLower) || projectNameLower.includes(sessionDirName))) { score += 15; reasons.push('Similar name'); } return { id: project.id, name: project.name, icon: project.icon, color: project.color, score, reasons }; }); // Sort by score descending scoredProjects.sort((a, b) => b.score - a.score); // Get top 3 suggestions const suggestions = scoredProjects .filter(p => p.score > 0) // Only include projects with positive scores .slice(0, 3); // Get all projects sorted by name const allProjects = projects .map(p => ({ id: p.id, name: p.name, icon: p.icon, color: p.color })) .sort((a, b) => a.name.localeCompare(b.name)); res.json({ success: true, suggestions, allProjects }); } catch (error) { console.error('Error getting project suggestions:', error); res.status(500).json({ error: 'Failed to get project suggestions' }); } }); // ===== Terminal API Endpoints ===== // Create a new terminal app.post('/claude/api/terminals', requireAuth, (req, res) => { try { const { workingDir, sessionId, mode } = req.body; const result = terminalService.createTerminal({ workingDir, sessionId, mode }); if (result.success) { res.json({ success: true, terminalId: result.terminalId, terminal: result.terminal }); } else { res.status(500).json({ error: result.error }); } } catch (error) { console.error('Error creating terminal:', error); res.status(500).json({ error: 'Failed to create terminal' }); } }); // Get list of active terminals app.get('/claude/api/terminals', requireAuth, (req, res) => { try { const terminals = terminalService.listTerminals(); res.json({ success: true, terminals }); } catch (error) { console.error('Error listing terminals:', error); res.status(500).json({ error: 'Failed to list terminals' }); } }); // Get specific terminal info app.get('/claude/api/terminals/:id', requireAuth, (req, res) => { try { const result = terminalService.getTerminal(req.params.id); if (result.success) { res.json(result); } else { res.status(404).json({ error: result.error }); } } catch (error) { console.error('Error getting terminal:', error); res.status(500).json({ error: 'Failed to get terminal' }); } }); // Set terminal mode app.post('/claude/api/terminals/:id/mode', requireAuth, (req, res) => { try { const { mode } = req.body; const result = terminalService.setTerminalMode(req.params.id, mode); if (result.success) { res.json({ success: true, mode: result.mode }); } else { res.status(404).json({ error: result.error }); } } catch (error) { console.error('Error setting terminal mode:', error); res.status(500).json({ error: 'Failed to set terminal mode' }); } }); // Attach terminal to session app.post('/claude/api/terminals/:id/attach', requireAuth, async (req, res) => { try { const { sessionId } = req.body; // Get the session const session = claudeService.getSession(sessionId); if (!session) { return res.status(404).json({ error: 'Session not found' }); } const result = terminalService.attachToSession(req.params.id, session); if (result.success) { res.json({ success: true }); } else { res.status(500).json({ error: result.error }); } } catch (error) { console.error('Error attaching terminal to session:', error); res.status(500).json({ error: 'Failed to attach terminal' }); } }); // Close terminal app.delete('/claude/api/terminals/:id', requireAuth, (req, res) => { try { const result = terminalService.closeTerminal(req.params.id); if (result.success) { res.json({ success: true }); } else { res.status(404).json({ error: result.error }); } } catch (error) { console.error('Error closing terminal:', error); res.status(500).json({ error: 'Failed to close terminal' }); } }); // Send input to terminal via HTTP (WebSocket workaround) app.post('/claude/api/terminals/:id/input', requireAuth, (req, res) => { try { const { data } = req.body; if (!data) { res.status(400).json({ error: 'Missing data parameter' }); return; } const result = terminalService.sendTerminalInput(req.params.id, data); if (result.success) { res.json({ success: true }); } else { res.status(404).json({ error: result.error }); } } catch (error) { console.error('Error sending terminal input:', error); res.status(500).json({ error: 'Failed to send input' }); } }); // Get terminal output via HTTP polling (bypasses WebSocket issue) app.get('/claude/api/terminals/:id/output', requireAuth, (req, res) => { try { const sinceIndex = parseInt(req.query.since) || 0; const result = terminalService.getTerminalOutput(req.params.id, sinceIndex); if (result.success) { res.json(result); } else { res.status(404).json({ error: result.error }); } } catch (error) { console.error('Error getting terminal output:', error); res.status(500).json({ error: 'Failed to get output' }); } }); // Resize terminal via HTTP app.post('/claude/api/terminals/:id/resize', requireAuth, (req, res) => { try { const { cols, rows } = req.body; if (!cols || !rows) { res.status(400).json({ error: 'Missing cols or rows parameter' }); return; } const result = terminalService.resizeTerminal(req.params.id, cols, rows); if (result.success) { res.json({ success: true }); } else { res.status(404).json({ error: result.error }); } } catch (error) { console.error('Error resizing terminal:', error); res.status(500).json({ error: 'Failed to resize terminal' }); } }); // Get recent directories for terminal picker app.get('/claude/api/files/recent-dirs', requireAuth, (req, res) => { try { // Get recent directories from terminal state or provide defaults const recentDirs = [ process.env.HOME, process.env.HOME + '/obsidian-vault', process.cwd() ]; // Add directories from active terminals const terminals = terminalService.listTerminals(); const terminalDirs = terminals.map(t => t.workingDir); // Merge and deduplicate const allDirs = [...new Set([...recentDirs, ...terminalDirs])]; res.json({ success: true, directories: allDirs }); } catch (error) { console.error('Error getting recent directories:', error); res.status(500).json({ error: 'Failed to get recent directories' }); } }); // Get saved terminal state for restoration app.get('/claude/api/claude/terminal-restore', requireAuth, async (req, res) => { try { const statePath = path.join(VAULT_PATH, '.claude-ide', 'terminal-state.json'); try { const stateData = await fs.promises.readFile(statePath, 'utf-8'); const state = JSON.parse(stateData); res.json({ success: true, state }); } catch (error) { // No saved state exists res.json({ success: true, state: null }); } } catch (error) { console.error('Error getting terminal state:', error); res.status(500).json({ error: 'Failed to get terminal state' }); } }); // Get local Claude CLI sessions app.get('/claude/api/claude/local-sessions', requireAuth, async (req, res) => { try { const claudeDir = path.join(process.env.HOME, '.claude'); const sessionsDir = path.join(claudeDir, 'sessions'); const localSessions = []; // Check if sessions directory exists try { const sessionDirs = await fs.promises.readdir(sessionsDir); for (const sessionDir of sessionDirs) { const sessionPath = path.join(sessionsDir, sessionDir); const stats = await fs.promises.stat(sessionPath); if (!stats.isDirectory()) continue; // Read session info const infoPath = path.join(sessionPath, 'info.json'); const infoData = await fs.promises.readFile(infoPath, 'utf-8'); const info = JSON.parse(infoData); // Check if session file exists const sessionFilePath = path.join(sessionPath, 'session.json'); let hasSessionFile = false; try { await fs.promises.access(sessionFilePath); hasSessionFile = true; } catch { // Session file doesn't exist } localSessions.push({ id: sessionDir, type: 'local', createdAt: info.createdAt || new Date(stats.mtime).toISOString(), lastActivity: info.lastMessageAt || new Date(stats.mtime).toISOString(), projectName: info.projectName || 'Local Session', status: 'local', // Local sessions don't have running status hasSessionFile }); } // Sort by last activity localSessions.sort((a, b) => { const dateA = new Date(a.lastActivity); const dateB = new Date(b.lastActivity); return dateB - dateA; }); } catch (error) { console.error('Error reading local sessions:', error); } res.json({ success: true, sessions: localSessions }); } catch (error) { console.error('Error getting local sessions:', error); res.status(500).json({ error: 'Failed to get local sessions' }); } }); // Start server and WebSocket server const server = app.listen(PORT, () => { console.log(`Obsidian Web Interface running on port ${PORT}`); console.log(`Access at: http://localhost:${PORT}/claude`); }); // Initialize terminal service WebSocket server terminalService.createServer(server); // WebSocket server for real-time chat const wss = new WebSocket.Server({ server, path: '/claude/api/claude/chat', verifyClient: (info, cb) => { // Parse session cookie const sessionCookie = info.req.headers.cookie?.match(/connect.sid=([^;]+)/); if (!sessionCookie) { console.log('WebSocket rejected: No session cookie'); return cb(false, 401, 'Unauthorized: No session cookie'); } // Store session ID for later use info.req.sessionId = sessionCookie[1]; // Accept the connection (we'll validate session fully after connection) console.log('WebSocket connection accepted for session:', sessionCookie[1].substring(0, 10) + '...'); cb(true); } }); // Store connected clients with their session IDs const clients = new Map(); wss.on('connection', (ws, req) => { const clientId = Math.random().toString(36).substr(2, 9); const sessionId = req.sessionId; console.log(`WebSocket client connected: ${clientId}, session: ${sessionId ? sessionId.substring(0, 10) : 'none'}...`); // Store client info clients.set(clientId, { ws, sessionId, userId: null }); ws.on('message', async (message) => { try { const data = JSON.parse(message); console.log('[WebSocket] Message received:', { type: data.type, sessionId: data.sessionId?.substring(0, 20) || 'none', hasCommand: !!data.command, commandLength: data.command?.length || 0 }); if (data.type === 'command') { const { sessionId, command } = data; console.log(`[WebSocket] Sending command to session ${sessionId}: ${command.substring(0, 50)}...`); // Send command to Claude Code try { claudeService.sendCommand(sessionId, command); console.log(`[WebSocket] āœ“ Command sent successfully to session ${sessionId}`); } catch (error) { console.error(`[WebSocket] āœ— Error sending command:`, error.message); ws.send(JSON.stringify({ type: 'error', 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; const client = clients.get(clientId); if (client) { client.sessionId = sessionId; console.log(`[WebSocket] Client ${clientId} subscribed to session ${sessionId}`); } } } catch (error) { console.error('[WebSocket] Error:', error); ws.send(JSON.stringify({ type: 'error', error: error.message })); } }); ws.on('close', () => { console.log(`WebSocket client disconnected: ${clientId}`); clients.delete(clientId); }); // Send connected message ws.send(JSON.stringify({ type: 'connected', message: 'Connected to Claude Code chat', clientId })); }); // Forward Claude Code output to all subscribed WebSocket clients claudeService.on('session-output', (output) => { console.log(`Session output for ${output.sessionId}:`, output.type); console.log(`Content preview:`, output.content.substring(0, 100)); // Send to all clients subscribed to this session let clientsSent = 0; clients.forEach((client, clientId) => { if (client.sessionId === output.sessionId && client.ws.readyState === WebSocket.OPEN) { try { client.ws.send(JSON.stringify({ type: 'output', sessionId: output.sessionId, data: output })); clientsSent++; console.log(`āœ“ Sent output to client ${clientId}`); } catch (error) { console.error(`Error sending to client ${clientId}:`, error); } } }); if (clientsSent === 0) { console.log(`⚠ No clients subscribed to session ${output.sessionId}`); } }); // Forward operations-detected event claudeService.on('operations-detected', (data) => { console.log(`Operations detected for session ${data.sessionId}:`, data.operations.length, 'operations'); clients.forEach((client, clientId) => { if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) { try { client.ws.send(JSON.stringify({ type: 'operations-detected', sessionId: data.sessionId, response: data.response, tags: data.tags, operations: data.operations })); } catch (error) { console.error(`Error sending operations to client ${clientId}:`, error); } } }); }); // Forward operations-executed event claudeService.on('operations-executed', (data) => { console.log(`Operations executed for session ${data.sessionId}`); clients.forEach((client, clientId) => { if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) { try { client.ws.send(JSON.stringify({ type: 'operations-executed', sessionId: data.sessionId, results: data.results })); } catch (error) { console.error(`Error sending execution results to client ${clientId}:`, error); } } }); }); // Forward operations-error event claudeService.on('operations-error', (data) => { console.error(`Operations error for session ${data.sessionId}:`, data.error); clients.forEach((client, clientId) => { if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) { try { client.ws.send(JSON.stringify({ type: 'operations-error', sessionId: data.sessionId, error: data.error })); } catch (error) { console.error(`Error sending error to client ${clientId}:`, error); } } }); }); // 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 app.post('/claude/api/log-error', express.json(), (req, res) => { const error = req.body; const timestamp = new Date().toISOString(); // Log with visual marker for Claude to notice console.error('\n🚨 [BROWSER_ERROR] ' + '='.repeat(60)); console.error('🚨 [BROWSER_ERROR] Time:', timestamp); console.error('🚨 [BROWSER_ERROR] Type:', error.type); console.error('🚨 [BROWSER_ERROR] Message:', error.message); if (error.url) console.error('🚨 [BROWSER_ERROR] URL:', error.url); if (error.line) console.error('🚨 [BROWSER_ERROR] Line:', error.line); if (error.stack) console.error('🚨 [BROWSER_ERROR] Stack:', error.stack); console.error('🚨 [BROWSER_ERROR] ' + '='.repeat(60) + '\n'); // Also write to error log file const fs = require('fs'); const errorLogPath = '/tmp/browser-errors.log'; 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'; console.log('šŸ¤– [AUTO_FIX] Triggering agent to fix error...'); const agent = spawn('node', [agentPath, 'process'], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ERROR_DATA: JSON.stringify(error) } }); agent.stdin.write(JSON.stringify(error)); agent.stdin.end(); agent.stdout.on('data', (data) => { console.log('šŸ¤– [AUTO_FIX]', data.toString()); }); agent.stderr.on('data', (data) => { console.error('šŸ¤– [AUTO_FIX] ERROR:', data.toString()); }); agent.on('close', (code) => { console.log(`šŸ¤– [AUTO_FIX] Agent exited with code ${code}`); }); 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...'); await terminalService.cleanup(); process.exit(0); }); process.on('SIGTERM', async () => { console.log('\nReceived SIGTERM, shutting down gracefully...'); await terminalService.cleanup(); process.exit(0); });