- Add WebSocket heartbeat (30s ping/pong) to prevent proxy timeouts - Add auto-reconnect with exponential backoff (1s-30s, max 10 attempts) - Add interactive AskUserQuestion rendering with clickable options - Add custom input field for free-text answers - Add smooth animations (hover, selection glow, checkbox scale) - Make interactive tool cards wider (max-w-2xl) without scrolling - Hide error badge and result section for interactive tools - Use TextareaAutosize for lag-free custom input - Add Skill, SlashCommand tool renderings - Add ThinkingBlock component for collapsible <thinking> tags 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1137 lines
38 KiB
JavaScript
1137 lines
38 KiB
JavaScript
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);
|
|
});
|