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, mkdirSync, writeFileSync } from 'fs'; import { join, basename, extname } from 'path'; import multer from 'multer'; import session from 'express-session'; import { createClient } from 'redis'; import RedisStore from 'connect-redis'; import cookieParser from 'cookie-parser'; // Auth modules import { authConfig, validateConfig } from './config/auth.js'; import { initializeOIDC } from './utils/oidc.js'; import { requireAuth, optionalAuth, authenticateWebSocket } from './middleware/auth.js'; import authRoutes from './routes/auth.js'; const app = express(); // Trust proxy - required for secure cookies behind reverse proxy (NPM) app.set('trust proxy', 1); // CORS configuration - allow credentials for cookies app.use(cors({ origin: authConfig.app.frontendUrl, credentials: true, })); app.use(express.json()); app.use(cookieParser()); // Session store reference (set after Redis connection) let sessionStore = null; const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; const DEBUG = process.env.DEBUG === 'true'; // Upload to /projects/.claude-uploads so Claude can access them via mounted volume const UPLOAD_DIR = process.env.UPLOAD_DIR || '/projects/.claude-uploads'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB // Allowed file types const ALLOWED_TYPES = { // Images 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', // Text/Code 'text/plain': '.txt', 'text/markdown': '.md', 'text/csv': '.csv', 'text/html': '.html', 'text/css': '.css', 'text/javascript': '.js', 'application/json': '.json', 'application/xml': '.xml', 'text/xml': '.xml', 'application/x-yaml': '.yaml', 'text/yaml': '.yaml' }; // Configure multer for file uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { const sessionId = req.params.sessionId || 'default'; const sessionDir = join(UPLOAD_DIR, sessionId); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } cb(null, sessionDir); }, filename: (req, file, cb) => { // Preserve original filename with timestamp prefix to avoid collisions const timestamp = Date.now(); const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); cb(null, `${timestamp}-${safeName}`); } }); const upload = multer({ storage, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: (req, file, cb) => { if (ALLOWED_TYPES[file.mimetype]) { cb(null, true); } else { // Also allow common code file extensions const ext = extname(file.originalname).toLowerCase(); const codeExtensions = ['.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.sh', '.bash', '.zsh', '.yml', '.yaml', '.toml', '.ini', '.conf', '.md', '.txt', '.json', '.xml', '.html', '.css', '.scss', '.less', '.sql', '.rb', '.php', '.swift', '.kt', '.scala', '.r', '.lua', '.pl', '.pm']; if (codeExtensions.includes(ext)) { cb(null, true); } else { cb(new Error(`File type not allowed: ${file.mimetype} (${ext})`)); } } } }); // Load hosts configuration const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json'; let hostsConfig = { hosts: {}, defaults: { scanSubdirs: true, maxDepth: 1 } }; function loadConfig() { try { if (existsSync(CONFIG_PATH)) { hostsConfig = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')); console.log('Loaded hosts config:', Object.keys(hostsConfig.hosts)); } else { console.log('No hosts.json found, using defaults'); } } catch (err) { console.error('Error loading config:', err); } } loadConfig(); // Store active Claude sessions const sessions = new Map(); // Control request counter for unique IDs let controlRequestCounter = 0; function generateRequestId() { return `req_${++controlRequestCounter}_${Date.now().toString(16)}`; } const server = createServer(app); const wss = new WebSocketServer({ server }); // WebSocket heartbeat interval (30 seconds) const WS_HEARTBEAT_INTERVAL = 30000; // Scan directory for projects function scanProjects(basePath, depth = 0, maxDepth = 1) { const projects = []; if (!existsSync(basePath)) return projects; try { const entries = readdirSync(basePath, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith('.')) { const fullPath = join(basePath, entry.name); projects.push({ path: fullPath, name: entry.name, type: 'directory' }); // Recurse if not at max depth if (depth < maxDepth - 1) { projects.push(...scanProjects(fullPath, depth + 1, maxDepth)); } } } } catch (err) { console.error(`Error scanning ${basePath}:`, err.message); } return projects; } // Function to register API routes (called after session middleware is set up) function registerApiRoutes() { // REST endpoint to list hosts (protected) app.get('/api/hosts', requireAuth, (req, res) => { const hosts = Object.entries(hostsConfig.hosts).map(([id, host]) => ({ id, name: host.name, description: host.description, color: host.color, icon: host.icon, connectionType: host.connection.type, isLocal: host.connection.type === 'local' })); res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' }); }); // REST endpoint to list projects for a host (protected) app.get('/api/projects', requireAuth, (req, res) => { const hostId = req.query.host || hostsConfig.defaults?.host || 'neko'; const host = hostsConfig.hosts[hostId]; if (!host) { return res.status(404).json({ error: `Host '${hostId}' not found` }); } // For SSH hosts, return the basePaths from config (can't scan remote directories) if (host.connection.type !== 'local') { const projects = host.basePaths.map(basePath => ({ path: basePath, name: basename(basePath), type: 'base', isBase: true })); return res.json({ projects, host: hostId, hostInfo: { name: host.name, color: host.color }, message: 'SSH host - showing base paths only' }); } const projects = []; const scanSubdirs = hostsConfig.defaults?.scanSubdirs ?? true; const maxDepth = hostsConfig.defaults?.maxDepth ?? 1; for (const basePath of host.basePaths) { // Add base path itself if (existsSync(basePath)) { projects.push({ path: basePath, name: basename(basePath), type: 'base', isBase: true }); // Scan subdirectories if enabled if (scanSubdirs) { projects.push(...scanProjects(basePath, 0, maxDepth)); } } } res.json({ projects, host: hostId, hostInfo: { name: host.name, color: host.color } }); }); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', activeSessions: sessions.size, timestamp: new Date().toISOString() }); }); // Browse directories on a host (for directory picker) app.get('/api/browse', requireAuth, async (req, res) => { const hostId = req.query.host || hostsConfig.defaults?.host || 'neko'; const path = req.query.path || '~'; const host = hostsConfig.hosts[hostId]; if (!host) { return res.status(404).json({ error: `Host '${hostId}' not found` }); } // For SSH hosts, execute ls command remotely if (host.connection.type === 'ssh') { const { host: sshHost, user, port = 22 } = host.connection; // Expand ~ to home directory, list only directories, format as JSON-friendly output const lsCmd = `cd ${path} 2>/dev/null && pwd && ls -1F 2>/dev/null | grep '/$' | sed 's/\\/$//'`; const sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port} ${user}@${sshHost} "${lsCmd}"`; try { const { exec } = await import('child_process'); const { promisify } = await import('util'); const execAsync = promisify(exec); const { stdout, stderr } = await execAsync(sshCmd, { timeout: 10000 }); const lines = stdout.trim().split('\n').filter(Boolean); const currentPath = lines[0] || path; const directories = lines.slice(1).map(name => ({ name, path: currentPath === '/' ? `/${name}` : `${currentPath}/${name}`, type: 'directory' })); // Add parent directory if not at root if (currentPath !== '/') { const parentPath = currentPath.split('/').slice(0, -1).join('/') || '/'; directories.unshift({ name: '..', path: parentPath, type: 'parent' }); } res.json({ currentPath, directories, host: hostId }); } catch (err) { console.error('Browse error:', err.message); res.status(500).json({ error: `Failed to browse: ${err.message}` }); } } else { // Local browsing try { const resolvedPath = path === '~' ? process.env.HOME || '/home' : path; if (!existsSync(resolvedPath)) { return res.status(404).json({ error: `Path not found: ${resolvedPath}` }); } const entries = readdirSync(resolvedPath); const directories = entries .filter(name => { try { return statSync(join(resolvedPath, name)).isDirectory() && !name.startsWith('.'); } catch { return false; } }) .map(name => ({ name, path: join(resolvedPath, name), type: 'directory' })); // Add parent directory if (resolvedPath !== '/') { const parentPath = join(resolvedPath, '..'); directories.unshift({ name: '..', path: parentPath, type: 'parent' }); } res.json({ currentPath: resolvedPath, directories, host: hostId }); } catch (err) { res.status(500).json({ error: `Failed to browse: ${err.message}` }); } } }); // File upload endpoint app.post('/api/upload/:sessionId', requireAuth, upload.array('files', 5), async (req, res) => { try { if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'No files uploaded' }); } const sessionId = req.params.sessionId; const session = sessions.get(sessionId); const isSSH = session?.host?.connection?.type === 'ssh'; const uploadedFiles = []; for (const file of req.files) { // Convert container path to host path for Claude // /projects/.claude-uploads/... -> /home/sumdex/projects/.claude-uploads/... let hostPath = file.path.replace('/projects/', '/home/sumdex/projects/'); // For SSH hosts, transfer file via SCP if (isSSH && session.host) { const { host: sshHost, user, port = 22 } = session.host.connection; const remotePath = `/tmp/.claude-uploads/${file.filename}`; try { // Create remote directory if needed const { execSync } = await import('child_process'); execSync(`ssh -o StrictHostKeyChecking=no -p ${port} ${user}@${sshHost} "mkdir -p /tmp/.claude-uploads"`, { timeout: 10000 }); // Transfer file via SCP execSync(`scp -o StrictHostKeyChecking=no -P ${port} "${file.path}" ${user}@${sshHost}:"${remotePath}"`, { timeout: 60000 // 60s for large files }); hostPath = remotePath; console.log(`[Upload] SCP transferred ${file.filename} to ${session.hostId}:${remotePath}`); } catch (scpErr) { console.error(`[Upload] SCP error for ${file.filename}:`, scpErr.message); // Fall back to local path (won't work but at least doesn't fail) } } uploadedFiles.push({ originalName: file.originalname, savedName: file.filename, path: hostPath, containerPath: file.path, size: file.size, mimeType: file.mimetype, isImage: file.mimetype.startsWith('image/') }); } console.log(`[Upload] Session ${sessionId}: ${uploadedFiles.length} files uploaded`); res.json({ files: uploadedFiles }); } catch (err) { console.error('[Upload] Error:', err); res.status(500).json({ error: err.message }); } }); // Error handler for multer app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB` }); } return res.status(400).json({ error: err.message }); } if (err) { return res.status(400).json({ error: err.message }); } next(); }); // Parse history content into messages function parseHistoryContent(content) { 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 } } return messages; } // Get session history for a project (supports SSH hosts) app.get('/api/history/:project', requireAuth, async (req, res) => { console.log(`[History] Request for project: ${req.params.project}, host: ${req.query.host}`); try { const projectPath = decodeURIComponent(req.params.project); const hostId = req.query.host; const host = hostId ? hostsConfig.hosts[hostId] : null; const isSSH = host?.connection?.type === 'ssh'; console.log(`[History] Resolved - projectPath: ${projectPath}, hostId: ${hostId}, isSSH: ${isSSH}`); // Convert project path to Claude's folder naming convention const projectFolder = projectPath.replace(/\//g, '-'); if (isSSH) { // Load history via SSH const { host: sshHost, user, port = 22 } = host.connection; const sshTarget = `${user}@${sshHost}`; const historyDir = `~/.claude/projects/${projectFolder}`; // Find latest non-empty session file via SSH (skip agent files and empty files) // Using a simpler approach: find non-empty files with find command const findCmd = `find ${historyDir} -maxdepth 1 -name '*.jsonl' ! -name 'agent-*' -size +0 -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-`; const { execSync } = await import('child_process'); try { const latestFile = execSync(`ssh -T -o StrictHostKeyChecking=no -p ${port} ${sshTarget} "${findCmd}"`, { encoding: 'utf-8', timeout: 10000 }).trim(); if (!latestFile) { return res.json({ messages: [], sessionId: null }); } // Read the last 2000 lines (to handle large history files) const content = execSync(`ssh -T -o StrictHostKeyChecking=no -p ${port} ${sshTarget} "tail -n 2000 '${latestFile}'"`, { encoding: 'utf-8', timeout: 30000, maxBuffer: 50 * 1024 * 1024 // 50MB buffer }); const sessionId = basename(latestFile).replace('.jsonl', ''); const messages = parseHistoryContent(content); console.log(`[History] SSH - Returning ${messages.length} messages from session ${sessionId}`); return res.json({ messages, sessionId, source: 'ssh' }); } catch (sshErr) { console.error('SSH history fetch error:', sshErr.message); return res.json({ messages: [], sessionId: null, error: sshErr.message }); } } // Local history const historyDir = `/home/node/.claude/projects/${projectFolder}`; if (!existsSync(historyDir)) { return res.json({ messages: [], sessionId: null }); } // Find the most recent non-agent, non-empty 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, size: statSync(join(historyDir, f)).size })) .filter(f => f.size > 0) // Skip empty files .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 messages = parseHistoryContent(content); res.json({ messages, sessionId }); } catch (err) { console.error('Error reading history:', err); res.status(500).json({ error: err.message }); } }); } // End of registerApiRoutes function wss.on('connection', async (ws, req) => { const sessionId = uuidv4(); console.log(`[${sessionId}] New WebSocket connection`); // Track connection health ws.isAlive = true; // Heartbeat to keep connection alive through proxies const heartbeatInterval = setInterval(() => { if (ws.readyState === ws.OPEN) { ws.ping(); } }, WS_HEARTBEAT_INTERVAL); ws.on('pong', () => { ws.isAlive = true; }); // Authenticate WebSocket connection let wsUser = null; if (authConfig.app.authEnabled && sessionStore) { wsUser = await authenticateWebSocket(req, sessionStore); if (!wsUser) { console.log(`[${sessionId}] WebSocket authentication failed - closing connection`); ws.close(1008, 'Unauthorized'); return; } console.log(`[${sessionId}] WebSocket authenticated as: ${wsUser.email}`); } else { // Auth disabled - use anonymous user wsUser = { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'], isAdmin: true }; } let claudeProcess = null; let currentProject = null; let currentHostId = null; // Track current host for restart let isInitialized = false; let currentPermissionMode = 'default'; let savedPermissionMode = 'default'; // Store mode before plan mode switch let inPlanMode = false; // Track if we're in plan mode (to require approval for ExitPlanMode) const pendingControlRequests = new Map(); const pendingPermissionRequests = new Map(); // Track tool permission requests // Cleanup stale pending requests (TTL: 30 seconds) const REQUEST_TTL = 30000; const cleanupInterval = setInterval(() => { const now = Date.now(); for (const [id, req] of pendingControlRequests) { if (req.createdAt && now - req.createdAt > REQUEST_TTL) { pendingControlRequests.delete(id); } } for (const [id, req] of pendingPermissionRequests) { if (req.createdAt && now - req.createdAt > REQUEST_TTL) { pendingPermissionRequests.delete(id); } } }, 60000); // Helper to set permission mode via control protocol (needs claudeProcess) const setPermissionModeViaControl = (mode) => { if (!claudeProcess) return; const modeRequestId = generateRequestId(); const modeRequest = { type: 'control_request', request_id: modeRequestId, request: { subtype: 'set_permission_mode', mode: mode } }; console.log(`[${sessionId}] Auto-switching permission mode to: ${mode}`); claudeProcess.stdin.write(JSON.stringify(modeRequest) + '\n'); pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode, createdAt: Date.now() }); }; const sendToClient = (type, data) => { if (ws.readyState === ws.OPEN) { try { ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() })); } catch (err) { console.error(`[${sessionId}] WebSocket send failed:`, err.message); } } }; const startClaudeSession = (projectPath, resume = true, hostId = null) => { if (claudeProcess) { console.log(`[${sessionId}] Killing existing Claude process`); claudeProcess.kill(); } currentProject = projectPath; currentHostId = hostId; // Save for potential restart // Get host config const host = hostId ? hostsConfig.hosts[hostId] : null; const isSSH = host?.connection?.type === 'ssh'; console.log(`[${sessionId}] Starting Claude in: ${projectPath} (resume: ${resume}, host: ${hostId || 'local'}, ssh: ${isSSH})`); const claudeArgs = [ '-p', '--output-format', 'stream-json', '--input-format', 'stream-json', '--include-partial-messages', '--verbose' // Note: No --dangerously-skip-permissions - we handle permissions via control protocol ]; // Add continue flag to resume most recent conversation if (resume) { claudeArgs.push('--continue'); } if (isSSH) { // SSH execution const { host: sshHost, user, port = 22 } = host.connection; const sshTarget = `${user}@${sshHost}`; // Use claudePath from config if specified, otherwise default to 'claude' const claudeBin = host.claudePath || 'claude'; // Build the remote command with PATH setup for non-login shells const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && ${claudeBin} ${claudeArgs.join(' ')}`; console.log(`[${sessionId}] SSH to ${sshTarget}:${port} - ${remoteCmd}`); claudeProcess = spawn('ssh', [ '-T', // Disable TTY (needed for JSON streaming) '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=no', '-p', String(port), sshTarget, remoteCmd ], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } }); } else { // Local execution claudeProcess = spawn('claude', claudeArgs, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectPath || '/projects', env: { ...process.env } }); } sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId, user: wsUser }); sendToClient('session_started', { sessionId, project: projectPath }); // Send control protocol initialization after process starts const initializeControlProtocol = () => { const requestId = generateRequestId(); const initRequest = { type: 'control_request', request_id: requestId, request: { subtype: 'initialize', hooks: null } }; console.log(`[${sessionId}] Sending control initialization`); claudeProcess.stdin.write(JSON.stringify(initRequest) + '\n'); pendingControlRequests.set(requestId, { type: 'initialize', createdAt: Date.now() }); }; // Small delay to ensure process is ready setTimeout(initializeControlProtocol, 100); // Handle stdout (JSON events) let pendingLine = ''; claudeProcess.stdout.on('data', (data) => { const chunk = data.toString(); if (DEBUG) console.log(`[${sessionId}] stdout chunk:`, chunk.substring(0, 200)); const parts = (pendingLine + chunk).split('\n'); pendingLine = parts.pop() || ''; for (const line of parts) { if (line.trim()) { if (DEBUG) console.log(`[${sessionId}] Processing line:`, line.substring(0, 100)); try { const event = JSON.parse(line); if (DEBUG) console.log(`[${sessionId}] Event type:`, event.type); // Handle control protocol requests (permission prompts from CLI) if (event.type === 'control_request') { const request = event.request; const requestId = event.request_id; if (request?.subtype === 'can_use_tool') { if (DEBUG) console.log(`[${sessionId}] Permission request for tool: ${request.tool_name}`); // Special handling for ExitPlanMode - always send to UI for approval const isPlanApproval = request.tool_name === 'ExitPlanMode'; // Track plan mode state for ExitPlanMode if (isPlanApproval && !inPlanMode) { savedPermissionMode = currentPermissionMode; inPlanMode = true; if (DEBUG) console.log(`[${sessionId}] Entering plan mode, saved mode: ${savedPermissionMode}`); } // Store the request so we know which tool is being approved/denied pendingPermissionRequests.set(requestId, { toolName: request.tool_name, toolInput: request.input, isPlanApproval, createdAt: Date.now() }); // Send to frontend for user approval sendToClient('permission_request', { requestId, toolName: request.tool_name, toolInput: request.input, permissionSuggestions: request.permission_suggestions, blockedPath: request.blocked_path, isPlanApproval // Flag for special UI treatment }); } continue; } // Handle control protocol responses if (event.type === 'control_response') { const response = event.response; const requestId = response?.request_id; const pending = pendingControlRequests.get(requestId); if (pending) { pendingControlRequests.delete(requestId); if (response.subtype === 'success') { if (pending.type === 'initialize') { isInitialized = true; console.log(`[${sessionId}] Control protocol initialized`); // Send available commands/models to frontend sendToClient('control_initialized', { commands: response.response?.commands, models: response.response?.models, account: response.response?.account }); } else if (pending.type === 'set_permission_mode') { currentPermissionMode = pending.mode; console.log(`[${sessionId}] Permission mode changed to: ${currentPermissionMode}`); sendToClient('permission_mode_changed', { mode: currentPermissionMode }); } } else if (response.subtype === 'error') { console.error(`[${sessionId}] Control request error:`, response.error); sendToClient('control_error', { requestId, error: response.error }); } } // Don't forward control responses as regular events continue; } // Debug: log all tool_use blocks in assistant messages if (event.type === 'assistant' && event.message?.content) { for (const block of event.message.content) { if (block.type === 'tool_use') { if (DEBUG) console.log(`[${sessionId}] Tool use detected: ${block.name}`); } } } // Note: ExitPlanMode is now handled exclusively via Control Protocol (can_use_tool) // to avoid tool_result/control_response confusion and message compaction issues sendToClient('claude_event', { event }); } catch (e) { // Non-JSON output, send as raw if (DEBUG) 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(); // Always log stderr for SSH connections (exit code 255 debugging) if (DEBUG || isSSH) 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()); if (DEBUG) console.log(`[${sessionId}] Received:`, data.type); switch (data.type) { case 'start_session': startClaudeSession(data.project || '/projects', data.resume !== false, data.host || null); 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 }] } }; if (DEBUG) 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; case 'stop_generation': // Kill the process and restart with --continue to resume session if (claudeProcess) { console.log(`[${sessionId}] Stop generation: killing process and restarting`); // Save current state for restart const restartProject = currentProject; const restartHost = currentHostId; const restartPermissionMode = currentPermissionMode; // Kill the process claudeProcess.kill('SIGKILL'); claudeProcess = null; isInitialized = false; // Notify frontend sendToClient('generation_stopped', { message: 'Generation stopped, reconnecting...', timestamp: Date.now() }); // Restart after a short delay setTimeout(() => { console.log(`[${sessionId}] Restarting session with --continue`); startClaudeSession(restartProject, true, restartHost); // Restore permission mode after initialization savedPermissionMode = restartPermissionMode; }, 500); } else { sendToClient('generation_stopped', { message: 'No active process', timestamp: Date.now() }); } break; case 'set_permission_mode': if (!claudeProcess) { sendToClient('error', { message: 'No active Claude session' }); return; } if (!isInitialized) { sendToClient('error', { message: 'Control protocol not yet initialized' }); return; } // Don't allow mode change while in plan mode - plan mode takes priority if (inPlanMode) { console.log(`[${sessionId}] Ignoring set_permission_mode while in plan mode (saving ${data.mode} for later)`); // Save what the user wanted so we can restore it after plan approval savedPermissionMode = data.mode; // Notify frontend that we're still in plan mode sendToClient('permission_mode', { mode: 'plan', reason: 'plan_mode_active' }); return; } const mode = data.mode; const validModes = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; if (!validModes.includes(mode)) { sendToClient('error', { message: `Invalid permission mode: ${mode}. Valid: ${validModes.join(', ')}` }); return; } const modeRequestId = generateRequestId(); const modeRequest = { type: 'control_request', request_id: modeRequestId, request: { subtype: 'set_permission_mode', mode: mode } }; console.log(`[${sessionId}] Setting permission mode to: ${mode}`); claudeProcess.stdin.write(JSON.stringify(modeRequest) + '\n'); pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode, createdAt: Date.now() }); break; case 'get_permission_mode': sendToClient('permission_mode', { mode: currentPermissionMode }); break; case 'permission_response': if (!claudeProcess) { sendToClient('error', { message: 'No active Claude session' }); return; } const pendingPerm = pendingPermissionRequests.get(data.requestId); if (pendingPerm) { pendingPermissionRequests.delete(data.requestId); } // All permission responses (including ExitPlanMode/plan approval) use control_response // This avoids tool_result issues when message history is compacted const permResponse = { type: 'control_response', response: { subtype: 'success', request_id: data.requestId, response: data.allow ? { behavior: 'allow', updated_input: data.updatedInput || null } : { behavior: 'deny', message: data.message || 'User denied permission' } } }; console.log(`[${sessionId}] Sending permission response: ${data.allow ? 'allow' : 'deny'} for ${data.requestId}`); claudeProcess.stdin.write(JSON.stringify(permResponse) + '\n'); // Handle plan mode state updates for ExitPlanMode if (pendingPerm?.isPlanApproval) { if (data.allow) { const modeToRestore = savedPermissionMode || 'default'; console.log(`[${sessionId}] ExitPlanMode approved - restoring mode to '${modeToRestore}'`); inPlanMode = false; savedPermissionMode = null; // Restore previous permission mode after a small delay setTimeout(() => { setPermissionModeViaControl(modeToRestore); }, 100); sendToClient('plan_mode_exited', { approved: true }); } else { console.log(`[${sessionId}] ExitPlanMode rejected`); sendToClient('plan_mode_exited', { approved: false }); } } break; // Note: plan_approval case removed - now handled via permission_response with isPlanApproval flag 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`); clearInterval(heartbeatInterval); clearInterval(cleanupInterval); if (claudeProcess) { claudeProcess.kill(); sessions.delete(sessionId); } }); ws.on('error', (err) => { console.error(`[${sessionId}] WebSocket error:`, err); }); }); // Initialize and start server async function startServer() { // Validate auth config if (!validateConfig()) { console.error('[Server] Auth configuration invalid'); if (authConfig.app.authEnabled) { process.exit(1); } } // Initialize Redis and session store if (authConfig.app.authEnabled) { try { console.log('[Server] Connecting to Redis...'); const redisClient = createClient({ url: authConfig.redis.url }); redisClient.on('error', err => console.error('[Redis] Error:', err)); await redisClient.connect(); console.log('[Server] Redis connected'); // Create session store sessionStore = new RedisStore({ client: redisClient }); // Configure session middleware app.use(session({ store: sessionStore, name: authConfig.session.name, secret: authConfig.session.secret, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: authConfig.session.secure, sameSite: 'lax', maxAge: authConfig.session.maxAge, domain: authConfig.session.domain, }, })); // Initialize OIDC client console.log('[Server] Initializing OIDC...'); await initializeOIDC(); console.log('[Server] OIDC initialized'); // Mount auth routes app.use('/auth', authRoutes); console.log('[Server] Auth routes mounted at /auth'); // Register API routes (after session middleware is set up) registerApiRoutes(); console.log('[Server] API routes registered'); } catch (error) { console.error('[Server] Failed to initialize auth:', error); process.exit(1); } } else { console.log('[Server] Authentication disabled'); // Register API routes (no session middleware needed when auth is disabled) registerApiRoutes(); console.log('[Server] API routes registered'); } // Start listening server.listen(PORT, HOST, () => { console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`); console.log(`WebSocket available at ws://${HOST}:${PORT}`); console.log(`Authentication: ${authConfig.app.authEnabled ? 'ENABLED' : 'DISABLED'}`); }); } // Start the server startServer().catch(err => { console.error('[Server] Fatal error:', err); process.exit(1); });