agent-orchestrator/packages/web/server/direct-terminal-ws.ts

242 lines
7.5 KiB
TypeScript

/**
* Direct WebSocket terminal server using node-pty.
* Connects browser xterm.js directly to tmux sessions via WebSocket.
*
* This bypasses ttyd and gives us control over terminal initialization,
* allowing us to implement the XDA (Extended Device Attributes) handler
* that tmux requires for clipboard support.
*/
import { createServer, type Server } from "node:http";
import { spawn } from "node:child_process";
import { WebSocketServer, WebSocket } from "ws";
import { spawn as ptySpawn, type IPty } from "node-pty";
import { homedir, userInfo } from "node:os";
import { findTmux, resolveTmuxSession, validateSessionId } from "./tmux-utils.js";
interface TerminalSession {
sessionId: string;
pty: IPty;
ws: WebSocket;
}
export interface DirectTerminalServer {
server: Server;
wss: WebSocketServer;
activeSessions: Map<string, TerminalSession>;
shutdown: () => void;
}
/**
* Create the direct terminal WebSocket server.
* Separated from listen() so tests can control lifecycle.
*/
export function createDirectTerminalServer(tmuxPath?: string): DirectTerminalServer {
const TMUX = tmuxPath ?? findTmux();
const activeSessions = new Map<string, TerminalSession>();
const server = createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
active: activeSessions.size,
sessions: Array.from(activeSessions.keys()),
}),
);
return;
}
res.writeHead(404);
res.end("Not found");
});
const wss = new WebSocketServer({
server,
path: "/ws",
});
wss.on("connection", (ws, req) => {
const url = new URL(req.url ?? "/", "ws://localhost");
const sessionId = url.searchParams.get("session");
if (!sessionId) {
console.error("[DirectTerminal] Missing session parameter");
ws.close(1008, "Missing session parameter");
return;
}
// Validate session ID format
if (!validateSessionId(sessionId)) {
console.error("[DirectTerminal] Invalid session ID:", sessionId);
ws.close(1008, "Invalid session ID");
return;
}
// Resolve tmux session name: try exact match first, then suffix match
// (hash-prefixed sessions like "8474d6f29887-ao-15" are accessed by user-facing ID "ao-15")
const tmuxSessionId = resolveTmuxSession(sessionId, TMUX);
if (!tmuxSessionId) {
console.error("[DirectTerminal] tmux session not found:", sessionId);
ws.close(1008, "Session not found");
return;
}
console.log(`[DirectTerminal] New connection for session: ${tmuxSessionId}`);
// Enable mouse mode for scrollback support
const mouseProc = spawn(TMUX, ["set-option", "-t", tmuxSessionId, "mouse", "on"]);
mouseProc.on("error", (err) => {
console.error(
`[DirectTerminal] Failed to set mouse mode for ${tmuxSessionId}:`,
err.message,
);
});
// Hide the green status bar for cleaner appearance
const statusProc = spawn(TMUX, ["set-option", "-t", tmuxSessionId, "status", "off"]);
statusProc.on("error", (err) => {
console.error(
`[DirectTerminal] Failed to hide status bar for ${tmuxSessionId}:`,
err.message,
);
});
// Build complete environment - node-pty requires proper env setup
const homeDir = process.env.HOME || homedir();
const currentUser = process.env.USER || userInfo().username;
const env = {
HOME: homeDir,
SHELL: process.env.SHELL || "/bin/bash",
USER: currentUser,
PATH: process.env.PATH || "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
TERM: "xterm-256color",
LANG: process.env.LANG || "en_US.UTF-8",
TMPDIR: process.env.TMPDIR || "/tmp",
};
let pty: IPty;
try {
console.log(`[DirectTerminal] Spawning PTY: tmux attach-session -t ${tmuxSessionId}`);
pty = ptySpawn(TMUX, ["attach-session", "-t", tmuxSessionId], {
name: "xterm-256color",
cols: 80,
rows: 24,
cwd: homeDir,
env,
});
console.log(`[DirectTerminal] PTY spawned successfully`);
} catch (err) {
console.error(`[DirectTerminal] Failed to spawn PTY:`, err);
ws.close(1011, `Failed to spawn terminal: ${err instanceof Error ? err.message : String(err)}`);
return;
}
const session: TerminalSession = { sessionId, pty, ws };
activeSessions.set(sessionId, session);
// PTY -> WebSocket
pty.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// PTY exit
pty.onExit(({ exitCode }) => {
console.log(`[DirectTerminal] PTY exited for ${sessionId} with code ${exitCode}`);
// Guard against stale exits: only delete if this pty is still the active one.
// A new connection may have already replaced this session entry.
if (activeSessions.get(sessionId)?.pty === pty) {
activeSessions.delete(sessionId);
}
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, "Terminal session ended");
}
});
// WebSocket -> PTY
ws.on("message", (data) => {
const message = data.toString("utf8");
// Handle resize messages (sent by xterm.js FitAddon)
if (message.startsWith("{")) {
try {
const parsed = JSON.parse(message) as { type?: string; cols?: number; rows?: number };
if (parsed.type === "resize" && parsed.cols && parsed.rows) {
pty.resize(parsed.cols, parsed.rows);
return;
}
} catch {
// Not JSON, treat as terminal input
}
}
// Normal terminal input
pty.write(message);
});
// WebSocket close
ws.on("close", () => {
console.log(`[DirectTerminal] WebSocket closed for ${sessionId}`);
// Guard against stale closes replacing a newer session's entry
if (activeSessions.get(sessionId)?.pty === pty) {
activeSessions.delete(sessionId);
}
pty.kill();
});
// WebSocket error
ws.on("error", (err) => {
console.error(`[DirectTerminal] WebSocket error for ${sessionId}:`, err.message);
// Guard against stale error handlers replacing a newer session's entry
if (activeSessions.get(sessionId)?.pty === pty) {
activeSessions.delete(sessionId);
}
pty.kill();
});
});
function shutdown() {
for (const [, session] of activeSessions) {
session.pty.kill();
session.ws.close(1001, "Server shutting down");
}
server.close();
}
return { server, wss, activeSessions, shutdown };
}
// --- Run as standalone script ---
// Only start the server when executed directly (not imported by tests)
const isMainModule = process.argv[1]?.endsWith("direct-terminal-ws.ts") ||
process.argv[1]?.endsWith("direct-terminal-ws.js");
if (isMainModule) {
const TMUX = findTmux();
console.log(`[DirectTerminal] Using tmux: ${TMUX}`);
const { server, shutdown } = createDirectTerminalServer(TMUX);
const PORT = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "14801", 10);
server.listen(PORT, () => {
console.log(`[DirectTerminal] WebSocket server listening on port ${PORT}`);
});
function handleShutdown(signal: string) {
console.log(`[DirectTerminal] Received ${signal}, shutting down...`);
shutdown();
const forceExitTimer = setTimeout(() => {
console.error("[DirectTerminal] Forced shutdown after timeout");
process.exit(1);
}, 5000);
forceExitTimer.unref();
}
process.on("SIGINT", () => handleShutdown("SIGINT"));
process.on("SIGTERM", () => handleShutdown("SIGTERM"));
}