/** * pi-ghostty-web * * Pi extension that provides web-based terminal access to the current * working directory using ghostty-web (Ghostty's VT100 parser via WASM) * on the frontend and a real PTY on the backend. * * Usage: * - Install: add "pi-ghostty-web" to packages in ~/.pi/agent/settings.json * - /web -- start the web terminal server (default port 7681) * - /web stop -- stop the server * - /web -- start on a specific port * * Each WebSocket connection spawns a new shell session in the pi CWD. */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createRequire } from "node:module"; import { networkInterfaces } from "node:os"; import { join, extname } from "node:path"; import { readFile } from "node:fs/promises"; import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import { spawn, type IPty } from "@lydell/node-pty"; import { WebSocketServer, type WebSocket } from "ws"; const DEFAULT_PORT = 7681; const HEARTBEAT_INTERVAL_MS = 20_000; const PORT_RETRY_MAX = 10; function getLanIp(): string { const nets = networkInterfaces(); // Prefer common interface names (WiFi/Ethernet) for (const name of ["en0", "en1", "eth0", "wlan0"]) { for (const net of nets[name] ?? []) { if (net.family === "IPv4" && !net.internal) return net.address; } } // Fallback: any LAN IP that isn't a bridge or VPN for (const name of Object.keys(nets)) { if (/^(bridge|utun|lo|veth|docker|br-)/.test(name)) continue; for (const net of nets[name] ?? []) { if (net.family === "IPv4" && !net.internal && /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(net.address)) { return net.address; } } } return "localhost"; } const MIME_TYPES: Record = { ".html": "text/html", ".js": "application/javascript", ".mjs": "application/javascript", ".cjs": "application/javascript", ".css": "text/css", ".json": "application/json", ".wasm": "application/wasm", }; // --------------------------------------------------------------------------- // Locate ghostty-web assets from node_modules // --------------------------------------------------------------------------- const require = createRequire(import.meta.url); function resolveGhosttyPaths(): { distDir: string; wasmPath: string } { const main = require.resolve("ghostty-web"); const pkgRoot = main.replace(/[/\\]dist[/\\].*$/, ""); return { distDir: join(pkgRoot, "dist"), wasmPath: join(pkgRoot, "ghostty-vt.wasm"), }; } // --------------------------------------------------------------------------- // HTML page served at / // --------------------------------------------------------------------------- function buildHtml(title: string): string { return ` ${title}
Connecting... ${title}
Windows
Panes
Session
`; } // --------------------------------------------------------------------------- // Server implementation // --------------------------------------------------------------------------- // PTY sessions persist across WebSocket reconnects. Each session has a // scrollback ring buffer so the client can restore terminal state after // the browser tab is backgrounded and the WebSocket is killed. const REPLAY_BUFFER_SIZE = 100 * 1024; // 100KB of recent output const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 min before orphan PTY is killed interface PtySession { id: string; pty: IPty; ws: WebSocket | null; replayBuf: string[]; replayStart: number; replayBytes: number; exitCode: number | null; killTimer: ReturnType | null; } function getShell(): string { if (process.platform === "win32") return process.env.COMSPEC || "cmd.exe"; return process.env.SHELL || "/bin/bash"; } let sessionIdCounter = 0; export default function (pi: ExtensionAPI) { let httpServer: ReturnType | null = null; let wss: WebSocketServer | null = null; let heartbeatTimer: ReturnType | null = null; const sessions = new Map(); let activePort: number | null = null; let ghosttyPaths: { distDir: string; wasmPath: string } | null = null; // ----------------------------------------------------------------------- // Serve files — cached in memory after first read // ----------------------------------------------------------------------- let cachedHtml: Buffer | null = null; const fileCache = new Map(); function handleHttp(req: IncomingMessage, res: ServerResponse) { const pathname = (req.url ?? "/").split("?")[0]; if (pathname === "/" || pathname === "/index.html") { if (!cachedHtml) { const title = `pi — ${pi.getSessionName() ?? "web terminal"}`; cachedHtml = Buffer.from(buildHtml(title)); } res.writeHead(200, { "Content-Type": "text/html", "Content-Length": cachedHtml.length }); res.end(cachedHtml); return; } if (!ghosttyPaths) { res.writeHead(500); res.end("ghostty-web assets not found"); return; } let filePath: string | null = null; if (pathname.startsWith("/dist/")) { filePath = join(ghosttyPaths.distDir, pathname.slice(6)); } else if (pathname === "/ghostty-vt.wasm") { filePath = ghosttyPaths.wasmPath; } if (filePath) { const cached = fileCache.get(filePath); if (cached) { res.writeHead(200, { "Content-Type": cached.contentType, "Content-Length": cached.data.length, "Cache-Control": "public, max-age=86400", }); res.end(cached.data); return; } // Read once, cache forever (assets don't change at runtime) readFile(filePath).then((data) => { const ext = extname(filePath!); const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; fileCache.set(filePath!, { data, contentType }); res.writeHead(200, { "Content-Type": contentType, "Content-Length": data.length, "Cache-Control": "public, max-age=86400", }); res.end(data); }).catch(() => { res.writeHead(404); res.end("Not Found"); }); return; } res.writeHead(404); res.end("Not Found"); } // ----------------------------------------------------------------------- // PTY + WebSocket bridge // ----------------------------------------------------------------------- // Replay buffer uses a circular index to avoid Array.shift() (O(n) per call). // When over budget, we advance replayStart and periodically compact. function appendReplay(session: PtySession, data: string) { session.replayBuf.push(data); session.replayBytes += data.length; // Trim from front by advancing start index while (session.replayBytes > REPLAY_BUFFER_SIZE && session.replayStart < session.replayBuf.length - 1) { session.replayBytes -= session.replayBuf[session.replayStart]!.length; session.replayBuf[session.replayStart] = null as any; // allow GC session.replayStart++; } // Compact when more than half the array is dead entries if (session.replayStart > session.replayBuf.length / 2 && session.replayStart > 100) { session.replayBuf = session.replayBuf.slice(session.replayStart); session.replayStart = 0; } } function getReplayChunks(session: PtySession): string[] { return session.replayBuf.slice(session.replayStart); } function createPtySession(cols: number, rows: number, cwd: string): PtySession { const id = String(++sessionIdCounter); // Clean env: strip tmux/screen vars so the PTY shell is a fresh session, // not one that thinks it's nested inside tmux/screen. const env = { ...process.env }; delete env.TMUX; delete env.TMUX_PANE; delete env.STY; // screen delete env.TERM_PROGRAM; delete env.TERM_PROGRAM_VERSION; env.TERM = "xterm-256color"; env.COLORTERM = "truecolor"; const ptyProcess = spawn(getShell(), [], { name: "xterm-256color", cols, rows, cwd, env, }); const session: PtySession = { id, pty: ptyProcess, ws: null, replayBuf: [], replayStart: 0, replayBytes: 0, exitCode: null, killTimer: null, }; sessions.set(id, session); // PTY output → replay buffer + active WebSocket ptyProcess.onData((data: string) => { appendReplay(session, data); if (session.ws && session.ws.readyState === session.ws.OPEN) { session.ws.send(data); } }); ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { session.exitCode = exitCode; if (session.ws && session.ws.readyState === session.ws.OPEN) { session.ws.send(`\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`); session.ws.close(); } // Clean up after exit if (session.killTimer) clearTimeout(session.killTimer); sessions.delete(id); }); return session; } function attachWs(session: PtySession, ws: WebSocket) { // Detach previous WebSocket if any if (session.ws) { try { session.ws.close(); } catch {} } // Cancel kill timer if (session.killTimer) { clearTimeout(session.killTimer); session.killTimer = null; } session.ws = ws; (ws as any).isAlive = true; ws.on("pong", () => { (ws as any).isAlive = true; }); ws.on("message", (raw: Buffer) => { const msg = raw.toString("utf8"); if (msg.startsWith("{")) { try { const parsed = JSON.parse(msg); if (parsed.type === "resize") { session.pty.resize(parsed.cols, parsed.rows); return; } } catch { // not JSON — fall through to pty write } } session.pty.write(msg); }); ws.on("close", () => { if (session.ws === ws) { session.ws = null; // Start kill timer — if no reconnect within timeout, kill PTY session.killTimer = setTimeout(() => { session.pty.kill(); sessions.delete(session.id); }, SESSION_TIMEOUT_MS); } }); ws.on("error", () => { // ignore socket errors }); } function handleWsConnection(ws: WebSocket, req: IncomingMessage, cwd: string) { const url = new URL(req.url ?? "/", `http://${req.headers.host}`); const cols = parseInt(url.searchParams.get("cols") || "80", 10); const rows = parseInt(url.searchParams.get("rows") || "24", 10); const reconnectId = url.searchParams.get("sid"); // Try to reconnect to existing session if (reconnectId && sessions.has(reconnectId)) { const session = sessions.get(reconnectId)!; if (session.exitCode !== null) { // PTY already exited — tell client to start fresh ws.send(JSON.stringify({ type: "session", sid: "", expired: true })); ws.close(); return; } // Reattach attachWs(session, ws); // Send session ID + replay buffer to restore terminal state ws.send(JSON.stringify({ type: "session", sid: session.id })); // Replay buffered output — client clears screen first for (const chunk of getReplayChunks(session)) { ws.send(chunk); } // Resize PTY to match new client dimensions session.pty.resize(cols, rows); return; } // New session const session = createPtySession(cols, rows, cwd); attachWs(session, ws); ws.send(JSON.stringify({ type: "session", sid: session.id })); } // ----------------------------------------------------------------------- // Start / Stop helpers // ----------------------------------------------------------------------- function startServer(port: number, cwd: string): Promise { return new Promise((resolve, reject) => { try { ghosttyPaths = resolveGhosttyPaths(); } catch (e) { reject(new Error("Could not locate ghostty-web package. Run npm install in the extension directory.")); return; } httpServer = createServer((req, res) => { try { handleHttp(req, res); } catch (err) { res.writeHead(500); res.end(String(err)); } }); wss = new WebSocketServer({ noServer: true }); httpServer.on("upgrade", (req, socket, head) => { const pathname = (req.url ?? "").split("?")[0]; if (pathname === "/ws") { wss!.handleUpgrade(req, socket, head, (ws) => { handleWsConnection(ws, req, cwd); }); } else { socket.destroy(); } }); // Heartbeat: ping every 20s, terminate unresponsive clients heartbeatTimer = setInterval(() => { if (!wss) return; for (const ws of wss.clients) { if (!(ws as any).isAlive) { ws.terminate(); continue; } (ws as any).isAlive = false; ws.ping(); } }, HEARTBEAT_INTERVAL_MS); // Port auto-increment on EADDRINUSE const tryListen = (p: number) => { httpServer!.removeAllListeners("error"); httpServer!.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE" && p < port + PORT_RETRY_MAX) { tryListen(p + 1); } else { reject(err.code === "EADDRINUSE" ? new Error(`Ports ${port}-${p} are all in use`) : err); } }); httpServer!.listen(p, "0.0.0.0", () => { activePort = p; resolve(p); }); }; tryListen(port); }); } function stopServer() { cachedHtml = null; fileCache.clear(); if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } for (const session of sessions.values()) { if (session.killTimer) clearTimeout(session.killTimer); session.pty.kill(); if (session.ws) try { session.ws.close(); } catch {} } sessions.clear(); if (wss) { wss.close(); wss = null; } if (httpServer) { httpServer.close(); httpServer = null; } activePort = null; } // ----------------------------------------------------------------------- // /web command // ----------------------------------------------------------------------- pi.registerCommand("web", { description: "Start/stop a web terminal (ghostty-web). Usage: /web [port|stop]", getArgumentCompletions: (prefix: string) => { const completions = [ { value: "stop", label: "stop — stop the web terminal server" }, ]; const filtered = completions.filter((c) => c.value.startsWith(prefix)); return filtered.length > 0 ? filtered : null; }, handler: async (args: string, ctx: ExtensionCommandContext) => { const arg = args?.trim() ?? ""; if (arg === "stop") { if (!activePort) { ctx.ui.notify("Web terminal is not running", "warning"); return; } stopServer(); ctx.ui.setStatus("web-access", undefined); ctx.ui.notify("Web terminal stopped", "info"); return; } if (activePort) { ctx.ui.notify(`Web terminal already running on http://localhost:${activePort}`, "warning"); return; } const port = arg ? parseInt(arg, 10) : DEFAULT_PORT; if (isNaN(port) || port < 1 || port > 65535) { ctx.ui.notify(`Invalid port: ${arg}`, "error"); return; } try { const boundPort = await startServer(port, ctx.cwd); const lanIp = getLanIp(); const localUrl = `http://localhost:${boundPort}`; const lanUrl = lanIp !== "localhost" ? `http://${lanIp}:${boundPort}` : null; ctx.ui.setStatus( "web-access", ctx.ui.theme.fg("accent", `web: ${lanUrl ?? localUrl}`), ); ctx.ui.notify( lanUrl ? `Web terminal: ${localUrl} (LAN: ${lanUrl})` : `Web terminal: ${localUrl}`, "info", ); } catch (err) { ctx.ui.notify(`Failed to start: ${err instanceof Error ? err.message : String(err)}`, "error"); } }, }); // ----------------------------------------------------------------------- // Cleanup on session shutdown // ----------------------------------------------------------------------- pi.on("session_shutdown", async (_event, _ctx) => { stopServer(); }); }