Files
claude-web-ui/backend/server.js
Nikolas Syring 38ab89932a feat: Add multi-host config and dynamic project scanning
Backend:
- Load hosts from config/hosts.json
- New /api/hosts endpoint listing available hosts
- Dynamic project scanning with configurable depth
- Support for local and SSH hosts (SSH execution coming next)

Frontend (by Web-UI Claude):
- Slash commands: /clear, /help, /export, /scroll, /new, /info
- Chat export as Markdown

Config:
- hosts.json defines hosts with connection info and base paths
- hosts.example.json as template (real config is gitignored)
- Each host has name, description, color, icon, basePaths

Next steps:
- SSH command execution for remote hosts
- Frontend host selector UI
- Multi-agent collaboration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:11:22 +01:00

386 lines
11 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 } from 'fs';
import { join, basename } 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';
// 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();
const server = createServer(app);
const wss = new WebSocketServer({ server });
// 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;
}
// REST endpoint to list hosts
app.get('/api/hosts', (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
app.get('/api/projects', (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` });
}
// Only local hosts for now
if (host.connection.type !== 'local') {
return res.json({
projects: [],
host: hostId,
message: 'SSH hosts not yet supported for project listing'
});
}
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()
});
});
// 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}`);
});