feat(KB-058): complete Step 3 — add WebSocket terminal routes and REST endpoints

This commit is contained in:
gsxdsm
2026-03-29 23:26:41 -07:00
parent 36cd94dbd5
commit 0b58183a5a
2 changed files with 232 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import { COLUMNS, VALID_TRANSITIONS, type PrInfo } from "@kb/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
import { terminalSessionManager } from "./terminal.js";
import { getTerminalService } from "./terminal-service.js";
import { listFiles, readFile, writeFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
import { fetchAllProviderUsage } from "./usage.js";
@@ -1850,6 +1851,91 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── PTY Terminal Routes (WebSocket-based) ────────────────────────────
/**
* POST /api/terminal/sessions
* Create a new PTY terminal session.
* Body: { cwd?: string, cols?: number, rows?: number }
* Returns: { sessionId: string, shell: string, cwd: string }
*/
router.post("/terminal/sessions", async (req, res) => {
try {
const { cwd, cols, rows } = req.body;
const terminalService = getTerminalService(store.getRootDir());
const session = await terminalService.createSession({
cwd,
cols: typeof cols === "number" ? cols : undefined,
rows: typeof rows === "number" ? rows : undefined,
});
if (!session) {
res.status(503).json({ error: "Failed to create session. Max sessions may be reached." });
return;
}
res.status(201).json({
sessionId: session.id,
shell: session.shell,
cwd: session.cwd,
});
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to create terminal session" });
}
});
/**
* GET /api/terminal/sessions
* List all active PTY terminal sessions.
* Returns: [{ id: string, cwd: string, shell: string, createdAt: string }]
*/
router.get("/terminal/sessions", async (_req, res) => {
try {
const terminalService = getTerminalService(store.getRootDir());
const sessions = terminalService.getAllSessions();
res.json(
sessions.map((s) => ({
id: s.id,
cwd: s.cwd,
shell: s.shell,
createdAt: s.createdAt.toISOString(),
}))
);
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to list sessions" });
}
});
/**
* DELETE /api/terminal/sessions/:id
* Kill a PTY terminal session.
* Returns: { killed: boolean }
*/
router.delete("/terminal/sessions/:id", (req, res) => {
try {
const { id } = req.params;
const terminalService = getTerminalService(store.getRootDir());
const killed = terminalService.killSession(id);
if (!killed) {
const session = terminalService.getSession(id);
if (!session) {
res.status(404).json({ error: "Session not found" });
} else {
res.status(400).json({ error: "Failed to kill session" });
}
return;
}
res.json({ killed: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── File API Routes ───────────────────────────────────────────────
/**

View File

@@ -7,6 +7,8 @@ import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { createApiRoutes } from "./routes.js";
import { createSSE } from "./sse.js";
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
import { getTerminalService, type TerminalSession } from "./terminal-service.js";
import { WebSocketServer, type WebSocket } from "ws";
import { terminalSessionManager } from "./terminal.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -28,6 +30,9 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
const app = express();
app.use(express.json());
// Initialize terminal service with project root
const terminalService = getTerminalService(store.getRootDir());
// Serve built React app
// Resolution order:
// 1. KB_CLIENT_DIR env override (explicit)
@@ -77,7 +82,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
});
});
// Terminal SSE endpoint for real-time command output streaming
// Legacy Terminal SSE endpoint (deprecated, use WebSocket instead)
app.get("/api/terminal/sessions/:id/stream", rateLimit(RATE_LIMITS.sse), (req, res) => {
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
@@ -147,5 +152,145 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
res.sendFile(join(clientDir, "index.html"));
});
// Store WebSocket server reference for external mounting
(app as ReturnType<typeof express> & { wsServer?: WebSocketServer }).wsServer = null as unknown as WebSocketServer;
return app;
}
/**
* Setup WebSocket terminal server
* Call this after creating the HTTP server to attach WebSocket handling
*/
export function setupTerminalWebSocket(
app: ReturnType<typeof express>,
server: import("http").Server
): void {
const terminalService = getTerminalService();
const wss = new WebSocketServer({
server,
path: "/api/terminal/ws",
});
// Store reference on app for access
(app as ReturnType<typeof express> & { wsServer?: WebSocketServer }).wsServer = wss;
wss.on("connection", (ws: WebSocket, req) => {
// Parse query params from URL
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) {
ws.close(4000, "Missing sessionId");
return;
}
const session = terminalService.getSession(sessionId);
if (!session) {
ws.close(4004, "Session not found");
return;
}
// Track if connection is alive
let isAlive = true;
let dataUnsub: (() => void) | null = null;
let exitUnsub: (() => void) | null = null;
// Send scrollback buffer first
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
if (scrollback) {
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
}
// Send connection info
ws.send(JSON.stringify({
type: "connected",
shell: session.shell,
cwd: session.cwd,
}));
// Subscribe to data events
dataUnsub = terminalService.onData((id, data) => {
if (id === sessionId && isAlive) {
try {
ws.send(JSON.stringify({ type: "data", data }));
} catch {
// WebSocket might be closing
}
}
});
// Subscribe to exit events
exitUnsub = terminalService.onExit((id, exitCode) => {
if (id === sessionId && isAlive) {
try {
ws.send(JSON.stringify({ type: "exit", exitCode }));
} catch {
// WebSocket might be closing
}
}
});
// Heartbeat ping/pong
const pingInterval = setInterval(() => {
if (!isAlive) {
ws.terminate();
return;
}
isAlive = false;
try {
ws.send(JSON.stringify({ type: "ping" }));
} catch {
ws.terminate();
}
}, 30000);
ws.on("pong", () => {
isAlive = true;
});
ws.on("message", (message: Buffer) => {
try {
const msg = JSON.parse(message.toString());
switch (msg.type) {
case "input":
if (typeof msg.data === "string") {
terminalService.write(sessionId, msg.data);
}
break;
case "resize":
if (typeof msg.cols === "number" && typeof msg.rows === "number") {
terminalService.resize(sessionId, msg.cols, msg.rows);
}
break;
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
case "pong":
isAlive = true;
break;
}
} catch {
// Ignore malformed messages
}
});
ws.on("close", () => {
isAlive = false;
clearInterval(pingInterval);
if (dataUnsub) dataUnsub();
if (exitUnsub) exitUnsub();
});
ws.on("error", () => {
isAlive = false;
clearInterval(pingInterval);
if (dataUnsub) dataUnsub();
if (exitUnsub) exitUnsub();
});
});
console.log(`Terminal WebSocket server mounted at /api/terminal/ws`);
}