- Backend: SSH execution via spawn() with -T flag for JSON streaming - Backend: PATH setup for non-login shells on remote hosts - Backend: History loading via SSH (tail -n 2000 for large files) - Frontend: Host selector UI with colored buttons in Sidebar - Frontend: Auto-select first project when host changes - Frontend: Pass host parameter to history and session APIs - Docker: Install openssh-client, mount SSH keys Enables running Claude sessions on remote hosts via SSH while viewing them through the web UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
472 lines
14 KiB
JavaScript
472 lines
14 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` });
|
|
}
|
|
|
|
// 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()
|
|
});
|
|
});
|
|
|
|
// 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', async (req, res) => {
|
|
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';
|
|
|
|
// 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 session file via SSH
|
|
const findCmd = `ls -t ${historyDir}/*.jsonl 2>/dev/null | grep -v agent- | head -1`;
|
|
|
|
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);
|
|
|
|
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 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 messages = parseHistoryContent(content);
|
|
|
|
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, hostId = null) => {
|
|
if (claudeProcess) {
|
|
console.log(`[${sessionId}] Killing existing Claude process`);
|
|
claudeProcess.kill();
|
|
}
|
|
|
|
currentProject = projectPath;
|
|
|
|
// 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',
|
|
'--dangerously-skip-permissions'
|
|
];
|
|
|
|
// 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}`;
|
|
|
|
// Build the remote command with PATH setup for non-login shells
|
|
const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && claude ${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 });
|
|
|
|
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, 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 }]
|
|
}
|
|
};
|
|
|
|
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}`);
|
|
});
|