Files
homelab/apps/harness/pty-server.js
Julia McGhee 7bb091d4b3
Some checks failed
CI / lint-and-test (push) Successful in 33s
CI / build (push) Has been cancelled
Deploy Production / deploy (push) Has been cancelled
Add interactive PTY Chat tab with xterm.js terminal emulator
Browser-based interactive terminal sessions with agent CLIs via
WebSocket + node-pty. Supports full TUI rendering (colors, cursor,
ctrl-c) through xterm.js in the browser.

Architecture: xterm.js ←WebSocket→ pty-server.js ←PTY→ agent CLI

- Extract shared buildAgentEnv() from executor into agent-env.ts
- Add internal /api/agents/[id]/env endpoint for PTY server
- Add pty-server.js (WebSocket + node-pty, max 3 sessions, 2hr cleanup)
- Add custom server.js wrapping Next.js with WebSocket upgrade
- Add ChatTab component with agent selector and terminal
- Wire CHAT tab into dashboard nav and render
- Configure serverExternalPackages for node-pty
- Update Dockerfile with build tools and custom server
- Bump k8s memory limit 1Gi → 2Gi for PTY sessions
2026-03-21 20:43:07 +00:00

170 lines
4.2 KiB
JavaScript

// @ts-check
const pty = require("node-pty");
const { WebSocketServer } = require("ws");
const MAX_SESSIONS = 3;
const MAX_SESSION_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
/** @type {Map<string, { pty: import("node-pty").IPty, ws: import("ws").WebSocket, createdAt: number }>} */
const sessions = new Map();
/**
* @param {import("http").Server} server
*/
function attachPtyWebSocket(server) {
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
if (url.pathname !== "/ws/pty") return;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
wss.on("connection", async (ws, req) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
const agentId = url.searchParams.get("agentId");
const cols = parseInt(url.searchParams.get("cols") || "80", 10);
const rows = parseInt(url.searchParams.get("rows") || "24", 10);
if (!agentId) {
ws.send(JSON.stringify({ type: "error", message: "agentId required" }));
ws.close();
return;
}
if (sessions.size >= MAX_SESSIONS) {
ws.send(
JSON.stringify({
type: "error",
message: `max ${MAX_SESSIONS} concurrent sessions`,
}),
);
ws.close();
return;
}
// Fetch agent config + env from the Next.js API
const port = process.env.PORT || 3100;
let config;
try {
const res = await fetch(
`http://127.0.0.1:${port}/api/agents/${encodeURIComponent(agentId)}/env`,
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
ws.send(
JSON.stringify({
type: "error",
message: body.error || `agent lookup failed: ${res.status}`,
}),
);
ws.close();
return;
}
config = await res.json();
} catch (err) {
ws.send(
JSON.stringify({
type: "error",
message: `failed to fetch agent config: ${err.message}`,
}),
);
ws.close();
return;
}
const sessionId = `${agentId}-${Date.now()}`;
let term;
try {
term = pty.spawn(config.command, config.args, {
name: "xterm-256color",
cols,
rows,
cwd: config.workDir,
env: config.env,
});
} catch (err) {
ws.send(
JSON.stringify({
type: "error",
message: `failed to spawn PTY: ${err.message}`,
}),
);
ws.close();
return;
}
sessions.set(sessionId, { pty: term, ws, createdAt: Date.now() });
ws.send(JSON.stringify({ type: "connected", sessionId }));
term.onData((data) => {
if (ws.readyState === ws.OPEN) {
ws.send(data);
}
});
term.onExit(({ exitCode }) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: "exit", code: exitCode }));
ws.close();
}
sessions.delete(sessionId);
});
ws.on("message", (data) => {
const msg = data.toString();
// Try to parse as JSON control message
if (msg.startsWith("{")) {
try {
const ctrl = JSON.parse(msg);
if (ctrl.type === "resize" && ctrl.cols && ctrl.rows) {
term.resize(ctrl.cols, ctrl.rows);
return;
}
} catch {
// Not JSON, treat as terminal input
}
}
term.write(msg);
});
ws.on("close", () => {
try {
term.kill();
} catch {
// Already dead
}
sessions.delete(sessionId);
});
});
// Periodic cleanup of stale sessions
setInterval(() => {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.createdAt > MAX_SESSION_AGE_MS) {
try {
session.pty.kill();
} catch {
// ignore
}
try {
session.ws.close();
} catch {
// ignore
}
sessions.delete(id);
}
}
}, CLEANUP_INTERVAL_MS);
return wss;
}
module.exports = { attachPtyWebSocket };