import express from 'express'; import { WebSocketServer } from 'ws'; import { createServer } from 'http'; import { spawn } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; import cors from 'cors'; import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; const app = express(); app.use(cors()); app.use(express.json()); const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; // Store active Claude sessions const sessions = new Map(); const server = createServer(app); const wss = new WebSocketServer({ server }); // REST endpoint to list available projects app.get('/api/projects', (req, res) => { const baseDirs = [ { path: '/projects', name: 'projects', description: 'Development projects' }, { path: '/docker', name: 'docker', description: 'Docker configurations' }, { path: '/stacks', name: 'stacks', description: 'Production stacks' } ]; const projects = []; for (const dir of baseDirs) { if (existsSync(dir.path)) { projects.push({ ...dir, type: 'directory' }); } } res.json(projects); }); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', activeSessions: sessions.size, timestamp: new Date().toISOString() }); }); // Get session history for a project app.get('/api/history/:project', (req, res) => { try { const projectPath = decodeURIComponent(req.params.project); // Convert project path to Claude's folder naming convention const projectFolder = projectPath.replace(/\//g, '-'); const historyDir = `/home/node/.claude/projects/${projectFolder}`; if (!existsSync(historyDir)) { return res.json({ messages: [], sessionId: null }); } // Find the most recent non-agent session file const files = readdirSync(historyDir) .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')) .map(f => ({ name: f, path: join(historyDir, f), mtime: statSync(join(historyDir, f)).mtime })) .sort((a, b) => b.mtime - a.mtime); if (files.length === 0) { return res.json({ messages: [], sessionId: null }); } const latestFile = files[0]; const sessionId = latestFile.name.replace('.jsonl', ''); const content = readFileSync(latestFile.path, 'utf-8'); const lines = content.split('\n').filter(l => l.trim()); const messages = []; for (const line of lines) { try { const event = JSON.parse(line); // Parse user messages if (event.type === 'user' && event.message?.content) { const textContent = event.message.content .filter(c => c.type === 'text') .map(c => c.text) .join(''); if (textContent && !event.tool_use_result) { messages.push({ type: 'user', content: textContent, timestamp: event.timestamp || Date.now() }); } } // Parse assistant messages if (event.type === 'assistant' && event.message?.content) { for (const block of event.message.content) { if (block.type === 'text' && block.text) { messages.push({ type: 'assistant', content: block.text, timestamp: event.timestamp || Date.now() }); } else if (block.type === 'tool_use') { messages.push({ type: 'tool_use', tool: block.name, input: block.input, toolUseId: block.id, timestamp: event.timestamp || Date.now() }); } } } // Parse tool results if (event.type === 'user' && event.tool_use_result) { messages.push({ type: 'tool_result', content: event.tool_use_result.content, toolUseId: event.tool_use_result.tool_use_id, isError: event.tool_use_result.is_error || false, timestamp: event.timestamp || Date.now() }); } } catch (e) { // Skip invalid JSON lines } } res.json({ messages, sessionId }); } catch (err) { console.error('Error reading history:', err); res.status(500).json({ error: err.message }); } }); wss.on('connection', (ws, req) => { const sessionId = uuidv4(); console.log(`[${sessionId}] New WebSocket connection`); let claudeProcess = null; let currentProject = null; const sendToClient = (type, data) => { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() })); } }; const startClaudeSession = (projectPath, resume = true) => { if (claudeProcess) { console.log(`[${sessionId}] Killing existing Claude process`); claudeProcess.kill(); } currentProject = projectPath; console.log(`[${sessionId}] Starting Claude in: ${projectPath} (resume: ${resume})`); const args = [ '-p', '--output-format', 'stream-json', '--input-format', 'stream-json', '--include-partial-messages', '--verbose', '--dangerously-skip-permissions' ]; // Add continue flag to resume most recent conversation if (resume) { args.push('--continue'); } claudeProcess = spawn('claude', args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectPath || '/projects', env: { ...process.env } }); sessions.set(sessionId, { process: claudeProcess, project: projectPath }); sendToClient('session_started', { sessionId, project: projectPath }); // Handle stdout (JSON events) let buffer = ''; claudeProcess.stdout.on('data', (data) => { const chunk = data.toString(); console.log(`[${sessionId}] stdout chunk:`, chunk.substring(0, 200)); buffer += chunk; const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.trim()) { console.log(`[${sessionId}] Processing line:`, line.substring(0, 100)); try { const event = JSON.parse(line); console.log(`[${sessionId}] Event type:`, event.type); sendToClient('claude_event', { event }); } catch (e) { // Non-JSON output, send as raw console.log(`[${sessionId}] Raw output:`, line.substring(0, 100)); sendToClient('raw_output', { content: line }); } } } }); // Handle stderr claudeProcess.stderr.on('data', (data) => { const content = data.toString(); console.log(`[${sessionId}] stderr:`, content); sendToClient('stderr', { content }); }); claudeProcess.on('close', (code) => { console.log(`[${sessionId}] Claude process exited with code ${code}`); sendToClient('session_ended', { code }); sessions.delete(sessionId); claudeProcess = null; }); claudeProcess.on('error', (err) => { console.error(`[${sessionId}] Claude process error:`, err); sendToClient('error', { message: err.message }); }); }; ws.on('message', (message) => { try { const data = JSON.parse(message.toString()); console.log(`[${sessionId}] Received:`, data.type); switch (data.type) { case 'start_session': startClaudeSession(data.project || '/projects', data.resume !== false); break; case 'user_message': if (!claudeProcess) { sendToClient('error', { message: 'No active Claude session' }); return; } const payload = { type: 'user', message: { role: 'user', content: [{ type: 'text', text: data.message }] } }; console.log(`[${sessionId}] Sending to Claude:`, data.message.substring(0, 50) + '...'); claudeProcess.stdin.write(JSON.stringify(payload) + '\n'); break; case 'stop_session': if (claudeProcess) { claudeProcess.kill(); claudeProcess = null; } break; default: console.log(`[${sessionId}] Unknown message type:`, data.type); } } catch (e) { console.error(`[${sessionId}] Error processing message:`, e); sendToClient('error', { message: e.message }); } }); ws.on('close', () => { console.log(`[${sessionId}] WebSocket closed`); if (claudeProcess) { claudeProcess.kill(); sessions.delete(sessionId); } }); ws.on('error', (err) => { console.error(`[${sessionId}] WebSocket error:`, err); }); }); server.listen(PORT, HOST, () => { console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`); console.log(`WebSocket available at ws://${HOST}:${PORT}`); });