Files
SuperCharged-Claude-Code-Up…/server.js
uroma a45b71e1e4 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>
2026-01-21 14:24:13 +00:00

2872 lines
85 KiB
JavaScript

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: `<pre><code>${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre>`,
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);
});