feat: Add persistent terminal with floating UI
- Server keeps PTY sessions alive on client disconnect - Output buffer replays history on reconnect - FloatingTerminal component accessible from any page - Responsive: floating window on desktop, fullscreen on mobile - macOS-style header with traffic light buttons
This commit is contained in:
205
server/index.ts
205
server/index.ts
@@ -1,6 +1,18 @@
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { spawn, type IPty } from '@skitee3000/bun-pty'
|
||||
|
||||
const PORT_HTTP = 4101
|
||||
const PORT_TERMINAL = 4103
|
||||
|
||||
// Terminal types
|
||||
interface TerminalSession {
|
||||
id: string
|
||||
pty: IPty
|
||||
outputBuffer: string[] // Buffer para replay al reconectar
|
||||
maxBufferSize: number
|
||||
clients: Set<any> // WebSockets conectados a esta sesión
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// Inicializar base de datos
|
||||
const db = new Database('agent-ui.db')
|
||||
@@ -1144,10 +1156,201 @@ Bun.serve({
|
||||
})
|
||||
|
||||
console.log(`[HTTP] API corriendo en http://localhost:${PORT_HTTP}`)
|
||||
|
||||
// =====================
|
||||
// Terminal WebSocket Server
|
||||
// =====================
|
||||
|
||||
const WORKING_DIR = process.cwd().replace(/[\\\/]server$/, '') // Go to project root
|
||||
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash'
|
||||
const shellArgs = process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : []
|
||||
|
||||
// Store active terminal sessions by ID (persisten entre reconexiones)
|
||||
const sessions = new Map<string, TerminalSession>()
|
||||
const DEFAULT_SESSION_ID = 'main'
|
||||
const MAX_BUFFER_LINES = 1000
|
||||
|
||||
// Helper: obtener o crear sesión
|
||||
function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession {
|
||||
let session = sessions.get(sessionId)
|
||||
|
||||
if (!session) {
|
||||
console.log(`[Terminal] Creating new session: ${sessionId}`)
|
||||
const pty = spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: WORKING_DIR
|
||||
})
|
||||
|
||||
session = {
|
||||
id: sessionId,
|
||||
pty,
|
||||
outputBuffer: [],
|
||||
maxBufferSize: MAX_BUFFER_LINES,
|
||||
clients: new Set(),
|
||||
createdAt: new Date()
|
||||
}
|
||||
|
||||
// Capturar output en buffer y enviar a clientes
|
||||
pty.onData((data: string) => {
|
||||
// Guardar en buffer (para replay)
|
||||
session!.outputBuffer.push(data)
|
||||
if (session!.outputBuffer.length > session!.maxBufferSize) {
|
||||
session!.outputBuffer.shift()
|
||||
}
|
||||
|
||||
// Enviar a todos los clientes conectados
|
||||
for (const ws of session!.clients) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'output', data }))
|
||||
} catch (e) {
|
||||
// Cliente desconectado
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Handle PTY exit
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
console.log(`[Terminal] Session ${sessionId} exited with code ${exitCode}`)
|
||||
for (const ws of session!.clients) {
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'exit',
|
||||
data: `\r\n\x1b[33mSession ended (code ${exitCode})\x1b[0m\r\n`
|
||||
}))
|
||||
} catch (e) {}
|
||||
}
|
||||
sessions.delete(sessionId)
|
||||
})
|
||||
|
||||
sessions.set(sessionId, session)
|
||||
console.log(`[Terminal] Session ${sessionId} created, PID: ${pty.pid}`)
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// Mapa de WebSocket a sessionId
|
||||
const wsToSession = new Map<any, string>()
|
||||
|
||||
const terminalServer = Bun.serve({
|
||||
port: PORT_TERMINAL,
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// Health check con info de sesiones
|
||||
if (url.pathname === '/health') {
|
||||
const sessionsInfo = Array.from(sessions.entries()).map(([id, s]) => ({
|
||||
id,
|
||||
clients: s.clients.size,
|
||||
pid: s.pty.pid,
|
||||
bufferSize: s.outputBuffer.length,
|
||||
createdAt: s.createdAt.toISOString()
|
||||
}))
|
||||
return Response.json({
|
||||
status: 'ok',
|
||||
sessions: sessionsInfo,
|
||||
cwd: WORKING_DIR
|
||||
})
|
||||
}
|
||||
|
||||
// Listar sesiones activas
|
||||
if (url.pathname === '/sessions') {
|
||||
const list = Array.from(sessions.keys())
|
||||
return Response.json({ sessions: list })
|
||||
}
|
||||
|
||||
// Check if this is a WebSocket upgrade request
|
||||
const upgradeHeader = req.headers.get('upgrade')
|
||||
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
|
||||
|
||||
if (upgradeHeader?.toLowerCase() === 'websocket') {
|
||||
// Obtener sessionId del query param o usar default
|
||||
const sessionId = url.searchParams.get('session') || DEFAULT_SESSION_ID
|
||||
const success = server.upgrade(req, { data: { sessionId } })
|
||||
console.log(`[Terminal] WebSocket upgrade for session "${sessionId}": ${success ? 'success' : 'failed'}`)
|
||||
if (success) {
|
||||
return undefined
|
||||
}
|
||||
return new Response('WebSocket upgrade failed', { status: 400 })
|
||||
}
|
||||
|
||||
return new Response('Terminal WebSocket Server - Persistent Sessions\n\nEndpoints:\n /health - Server status\n /sessions - List active sessions\n ws://...?session=<id> - Connect to session', { status: 200 })
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
const sessionId = (ws.data as any)?.sessionId || DEFAULT_SESSION_ID
|
||||
console.log(`[Terminal] Client connecting to session: ${sessionId}`)
|
||||
|
||||
try {
|
||||
const session = getOrCreateSession(sessionId)
|
||||
session.clients.add(ws)
|
||||
wsToSession.set(ws, sessionId)
|
||||
|
||||
// Enviar info de conexión
|
||||
ws.send(JSON.stringify({
|
||||
type: 'connected',
|
||||
sessionId: session.id,
|
||||
isNew: session.outputBuffer.length === 0
|
||||
}))
|
||||
|
||||
// Replay del buffer si hay historial
|
||||
if (session.outputBuffer.length > 0) {
|
||||
console.log(`[Terminal] Replaying ${session.outputBuffer.length} buffer entries`)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'replay',
|
||||
data: session.outputBuffer.join('')
|
||||
}))
|
||||
}
|
||||
|
||||
console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`)
|
||||
} catch (e: any) {
|
||||
console.error('[Terminal] Error:', e)
|
||||
ws.send(JSON.stringify({ type: 'error', message: e.message }))
|
||||
}
|
||||
},
|
||||
message(ws, message) {
|
||||
try {
|
||||
const msg = JSON.parse(message as string)
|
||||
const sessionId = wsToSession.get(ws)
|
||||
if (!sessionId) return
|
||||
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
if (msg.type === 'input') {
|
||||
session.pty.write(msg.data)
|
||||
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||
session.pty.resize(msg.cols, msg.rows)
|
||||
console.log(`[Terminal] Session ${sessionId} resized to ${msg.cols}x${msg.rows}`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[Terminal] Error:', e)
|
||||
}
|
||||
},
|
||||
close(ws) {
|
||||
const sessionId = wsToSession.get(ws)
|
||||
if (sessionId) {
|
||||
const session = sessions.get(sessionId)
|
||||
if (session) {
|
||||
session.clients.delete(ws)
|
||||
console.log(`[Terminal] Client left session ${sessionId} (${session.clients.size} clients remaining)`)
|
||||
// NO matamos el PTY - la sesión persiste
|
||||
}
|
||||
wsToSession.delete(ws)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`[Terminal] WebSocket corriendo en ws://localhost:${PORT_TERMINAL}`)
|
||||
console.log('')
|
||||
console.log('='.repeat(50))
|
||||
console.log('Agent UI API Server iniciado')
|
||||
console.log('Agent UI Server iniciado')
|
||||
console.log(` API: http://localhost:${PORT_HTTP}`)
|
||||
console.log(` Terminal: ws://localhost:${PORT_TERMINAL}`)
|
||||
console.log(` Working Dir: ${WORKING_DIR}`)
|
||||
console.log('')
|
||||
console.log('WebMCP se inicia por separado con Claude Code MCP')
|
||||
console.log('='.repeat(50))
|
||||
|
||||
Reference in New Issue
Block a user