/** * pi-web-ui Mirror Server Extension * * Starts a WebSocket + HTTP server inside the running Pi process, * allowing a browser to connect and mirror the TUI session in real-time. * * - Forwards all Pi events to connected browser clients * - Accepts commands from the browser and executes them via the extension API * - Serves static files for the pi-web-ui frontend * - Sends full state snapshot on client connect (messages, model, etc.) */ import * as fs from "node:fs"; import * as http from "node:http"; import * as path from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { WebSocket, WebSocketServer } from "ws"; type SettingsData = { port?: string | number; host?: string; disabled?: boolean; }; type BrowserImage = { data?: string; mimeType?: string; }; type BrowserCommand = { id?: string; type?: string; message?: string; images?: BrowserImage[]; streamingBehavior?: string; provider?: string; modelId?: string; level?: "off" | "minimal" | "low" | "medium" | "high"; name?: string; entryId?: string; outputPath?: string; enabled?: boolean; }; type BrowserResponse = { type: "response"; command: string; success: boolean; id?: string; data?: unknown; error?: string; }; type TextContentBlock = { type: "text"; text: string; }; type ImageContentBlock = { type: "image"; data: string; mimeType: "image/png" | "image/jpeg" | "image/gif" | "image/webp"; }; type SessionEntry = { type?: string; id?: string; timestamp?: string; cwd?: string; name?: string; message?: { role?: string; content?: unknown; }; }; type ParsedSession = { id: string; timestamp: string; name: string | null; firstMessage: string | null; cwd: string | null; }; type SessionListItem = ParsedSession & { file: string; filePath: string; mtime: number; tmux?: true; }; type ProjectSessionGroup = { path: string; dirName: string; sessions: SessionListItem[]; }; type FileListItem = { name: string; path: string; isDirectory: boolean; size: number | null; mtime: number; }; type SearchMatch = { role: string; snippet: string; }; type SearchResult = { filePath: string; project: string; sessionId: string; sessionName: string; sessionTimestamp: string; firstMessage: string; matches: SearchMatch[]; }; type ModelCandidate = { provider?: string; id?: string; }; type ExtensionAPIWithEvents = ExtensionAPI & { events?: { on?: (eventType: string, listener: (payload: unknown) => void) => void; }; }; type AliveWebSocket = WebSocket & { isAlive?: boolean; }; type WritableSocket = Pick; type NodeError = Error & { code?: string; }; const AGENT_DIR = path.resolve( (process.env.PI_CODING_AGENT_DIR || path.join(process.env.HOME || "~", ".pi/agent")).replace( /^~(?=$|\/)/, process.env.HOME || "~", ), ); const CUSTOM_SESSIONS_DIR = process.env.PI_CODING_AGENT_SESSION_DIR ? path.resolve(process.env.PI_CODING_AGENT_SESSION_DIR.replace(/^~(?=$|\/)/, process.env.HOME || "~")) : null; const SESSIONS_DIR = CUSTOM_SESSIONS_DIR || path.join(AGENT_DIR, "sessions"); const CUSTOM_SESSIONS_GROUP = "__custom__"; // Load pi-web-ui settings from the Pi agent directory (falls back to env vars) function loadSettings(): { port: number; host: string; autoStart: boolean; } { let settings: SettingsData = {}; try { const settingsPath = path.join(AGENT_DIR, "settings.json"); settings = ( JSON.parse(fs.readFileSync(settingsPath, "utf8")) as { "pi-web-ui"?: SettingsData; } )["pi-web-ui"] || {}; } catch {} return { port: parseInt(String(process.env.PI_WEB_UI_PORT || settings.port || "3001"), 10), host: process.env.PI_WEB_UI_HOST || settings.host || "127.0.0.1", autoStart: !( process.env.PI_WEB_UI_DISABLED === "1" || process.env.PI_WEB_UI_DISABLED === "true" || settings.disabled === true ), }; } const SETTINGS = loadSettings(); const PORT = SETTINGS.port; const HOST = SETTINGS.host; const AUTO_START = SETTINGS.autoStart; // @ts-expect-error — __dirname is provided by jiti at runtime const STATIC_DIR = process.env.PI_WEB_UI_STATIC_DIR || findStaticDir(); function findStaticDir(): string { const candidates: string[] = []; const seen = new Set(); const addCandidate = (dir: string) => { const normalized = path.resolve(dir); if (seen.has(normalized)) return; seen.add(normalized); candidates.push(normalized); }; // 1) Bundled React build output, then legacy public fallback. addCandidate(path.resolve(__dirname, "dist")); addCandidate(path.resolve(__dirname, "../dist")); addCandidate(path.resolve(__dirname, "public")); addCandidate(path.resolve(__dirname, "../public")); // 2) Installed package path (for npm-installed extension execution) try { // eslint-disable-next-line @typescript-eslint/no-var-requires const pkgPath = require.resolve("@kkkiio/pi-web-ui/package.json"); const pkgDir = path.dirname(pkgPath); addCandidate(path.join(pkgDir, "dist")); addCandidate(path.join(pkgDir, "public")); } catch {} // 3) Development fallback from current working directory addCandidate(path.resolve(process.cwd(), "dist")); addCandidate(path.resolve(process.cwd(), "public")); addCandidate(path.resolve(process.cwd(), "node_modules/@kkkiio/pi-web-ui/dist")); addCandidate(path.resolve(process.cwd(), "node_modules/@kkkiio/pi-web-ui/public")); for (const candidate of candidates) { if (fs.existsSync(path.join(candidate, "index.html"))) return candidate; } return path.resolve(process.cwd(), "dist"); } // MIME types for static file serving const MIME_TYPES: Record = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".json": "application/json", ".map": "application/json", ".webmanifest": "application/manifest+json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", }; export default function (pi: ExtensionAPI) { let server: http.Server | null = null; let wss: WebSocketServer | null = null; let heartbeatTimer: NodeJS.Timeout | null = null; const clients = new Set(); // Store latest context reference for use in command handlers let latestCtx: ExtensionContext | null = null; let latestNavigateTree: ((targetId: string) => Promise<{ cancelled: boolean; editorText?: string }>) | null = null; // ═══════════════════════════════════════ // Helper: send to one client // ═══════════════════════════════════════ function sendTo(ws: WritableSocket, data: unknown) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } } // ═══════════════════════════════════════ // Helper: broadcast to all clients // ═══════════════════════════════════════ function broadcast(data: unknown) { const json = JSON.stringify(data); for (const client of clients) { if (client.readyState === WebSocket.OPEN) { client.send(json); } } } let mirrorUrl = ""; let mirrorStatusBase = ""; function updateMirrorStatus() { if (!mirrorStatusBase) { latestCtx?.ui.setStatus("webui", ""); return; } const clientCount = clients.size; const clientText = clientCount > 0 ? ` • ${clientCount} web ${clientCount === 1 ? "client" : "clients"}` : ""; latestCtx?.ui.setStatus("webui", `${mirrorStatusBase}${clientText}`); } // ═══════════════════════════════════════ // Helper: stop the server // ═══════════════════════════════════════ function stopServer() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } if (wss) { for (const client of clients) { client.close(); } clients.clear(); try { wss.close(); } catch {} wss = null; } if (server) { try { server.close(); } catch {} server = null; } mirrorUrl = ""; mirrorStatusBase = ""; } // ═══════════════════════════════════════ // /webui-stop and /webui-start commands // ═══════════════════════════════════════ pi.registerCommand("webui-stop", { description: "Stop the pi-web-ui server", handler: async (_args, ctx) => { if (!server) { ctx.ui.notify("pi-web-ui is not running", "warning"); return; } stopServer(); ctx.ui.setStatus("webui", ""); ctx.ui.notify("pi-web-ui server stopped", "info"); }, }); pi.registerCommand("webui-start", { description: "Start the pi-web-ui server", handler: async (_args, ctx) => { if (server) { ctx.ui.notify(`pi-web-ui is already running at ${mirrorUrl}`, "warning"); return; } latestCtx = ctx; startServer(); ctx.ui.notify("pi-web-ui server starting...", "info"); }, }); // ═══════════════════════════════════════ // /webui command — open Pi Web UI in browser // ═══════════════════════════════════════ pi.registerCommand("webui", { description: "Open Pi Web UI in browser", handler: async (_args, ctx) => { if (!mirrorUrl) { ctx.ui.notify("pi-web-ui server not running yet", "warning"); return; } const { exec } = require("node:child_process"); exec(`open "${mirrorUrl}"`); ctx.ui.notify(`Opened ${mirrorUrl}`, "info"); // Capture navigateTree for use by WebSocket handler (ADR 0003) latestNavigateTree = (targetId) => ctx.navigateTree(targetId); broadcast({ type: "state", advancedFeatures: true }); }, }); // ═══════════════════════════════════════ // Event forwarding — subscribe to all Pi events // ═══════════════════════════════════════ const eventTypes = [ "agent_start", "agent_end", "turn_start", "turn_end", "message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_update", "tool_execution_end", "auto_compaction_start", "auto_compaction_end", "auto_retry_start", "auto_retry_end", "model_select", ] as const; for (const eventType of eventTypes) { pi.on(eventType as Parameters[0], async (event: unknown, ctx: ExtensionContext) => { latestCtx = ctx; const eventPayload = typeof event === "object" && event !== null ? (event as Record) : {}; // Forward event to all connected browser clients // Wrap in { type: "event", event: ... } to match the existing frontend protocol broadcast({ type: "event", event: { type: eventType, ...eventPayload }, }); }); } const subagentEventTypes = [ "subagents:ready", "subagents:created", "subagents:started", "subagents:completed", "subagents:failed", "subagents:steered", "subagents:compacted", "subagents:scheduled", "subagents:scheduler_ready", "subagents:settings_loaded", "subagents:settings_changed", ] as const; for (const eventType of subagentEventTypes) { (pi as ExtensionAPIWithEvents).events?.on?.(eventType, (payload: unknown) => { broadcast({ type: "event", event: { type: eventType, payload } }); }); } // Also capture context from session events // Auto-title: collect user messages and generate a title after a few turns let turnCount = 0; let titleSet = false; let userMessages: string[] = []; pi.on("session_start", async (_event, ctx) => { latestCtx = ctx; latestNavigateTree = null; broadcast({ type: "state", advancedFeatures: false }); turnCount = 0; titleSet = false; userMessages = []; }); pi.on("turn_start", async (_event, _ctx) => { turnCount++; }); // Capture user messages for title generation via message_start pi.on("message_start", async (event, _ctx) => { if (titleSet) return; const msg = event.message; if (!msg || msg.role !== "user") return; const content = msg.content; let text = ""; if (typeof content === "string") text = content; else if (Array.isArray(content)) { const tb = content.find( (b): b is TextContentBlock => typeof b === "object" && b !== null && (b as { type?: unknown }).type === "text" && typeof (b as { text?: unknown }).text === "string", ); if (tb) text = tb.text; } if (text) userMessages.push(text.substring(0, 300)); }); pi.on("turn_end", async (_event, _ctx) => { if (titleSet || turnCount < 2) return; const sessionName = pi.getSessionName(); if (sessionName && sessionName !== "New Session" && sessionName !== "Untitled") { titleSet = true; return; } // Generate title from collected messages const title = generateSessionTitle(userMessages); if (title) { pi.setSessionName(title); titleSet = true; // Broadcast to connected clients broadcast({ type: "event", event: { type: "session_name", name: title }, }); } }); function generateSessionTitle(messages: string[]): string | null { if (messages.length === 0) return null; // Find first substantive message (skip greetings and memory instructions) const greetings = /^(hey|hello|hi|morning|good morning|howdy|yo|sup)[\s!.:,]*$/i; const memoryInstructions = /read (your |the )?(memory|seed|persona|working) files/i; let bestMessage = ""; for (const msg of messages) { const cleaned = msg.trim(); if (greetings.test(cleaned)) continue; if (memoryInstructions.test(cleaned)) continue; if (cleaned.length < 10) continue; bestMessage = cleaned; break; } if (!bestMessage) { // Fall back to first message with any content bestMessage = messages.find((m) => m.trim().length > 0) || ""; } if (!bestMessage) return null; // Extract a clean title: first sentence or clause, max ~60 chars let title = bestMessage .replace(/^(ok |okay |so |actually |hey |please |can you |could you |i want(ed)? to |i wanna |let'?s )/i, "") .replace(/\n.*/s, "") // first line only .trim(); // Take first sentence const sentenceEnd = title.search(/[.!?]\s/); if (sentenceEnd > 10 && sentenceEnd < 80) { title = title.substring(0, sentenceEnd); } // Truncate cleanly if (title.length > 60) { const spaceIdx = title.lastIndexOf(" ", 57); title = `${title.substring(0, spaceIdx > 20 ? spaceIdx : 57)}…`; } // Capitalize first letter title = title.charAt(0).toUpperCase() + title.slice(1); return title; } // ═══════════════════════════════════════ // Build state snapshot for new connections // ═══════════════════════════════════════ async function buildStateSnapshot(ctx: ExtensionContext) { // Get session entries for message history const entries = ctx.sessionManager.getBranch(); // Get model info const model = ctx.model; const thinkingLevel = pi.getThinkingLevel(); const sessionName = pi.getSessionName(); const sessionFile = ctx.sessionManager.getSessionFile(); // Context usage const contextUsage = ctx.getContextUsage(); return { type: "mirror_sync", entries, model, thinkingLevel, sessionName, sessionFile, isStreaming: !ctx.isIdle(), contextUsage, }; } // ═══════════════════════════════════════ // Handle commands from browser clients // ═══════════════════════════════════════ async function handleCommand(ws: WritableSocket, command: BrowserCommand) { const id = command.id; const ctx = latestCtx; const success = (cmd: string, data?: unknown) => { const resp: BrowserResponse = { type: "response", command: cmd, success: true, id, }; if (data !== undefined) resp.data = data; return resp; }; const error = (cmd: string, message: string) => { return { type: "response", command: cmd, success: false, error: message, id, }; }; try { switch (command.type) { // ─── Prompting ─── case "prompt": { const message = command.message || ""; if (ctx && !ctx.isIdle()) { const behavior = command.streamingBehavior || "steer"; if (behavior === "steer") { pi.sendUserMessage(message, { deliverAs: "steer" }); } else { pi.sendUserMessage(message, { deliverAs: "followUp" }); } } else { // Build content with optional images const images = Array.isArray(command.images) ? command.images : []; if (images.length) { const validMimes = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const content: Array = [ { type: "text", text: message || "(see attached image)" }, ]; for (const img of images) { if (!img.data || typeof img.data !== "string") { continue; } // Strip data URL prefix if accidentally included const data = img.data.includes(",") ? img.data.split(",")[1] : img.data; const mimeType = (validMimes.includes(img.mimeType) ? img.mimeType : "image/png") as | "image/png" | "image/jpeg" | "image/gif" | "image/webp"; const imageBlock = { type: "image" as const, data: data, mimeType: mimeType, }; if (!imageBlock.mimeType) { imageBlock.mimeType = "image/png"; } content.push(imageBlock); } // Only send content array if we actually have images, otherwise just text const hasImages = content.some((c) => c.type === "image"); if (hasImages) { pi.sendUserMessage(content); } else { pi.sendUserMessage(message); } } else { pi.sendUserMessage(message); } } sendTo(ws, success("prompt")); break; } case "steer": { pi.sendUserMessage(command.message || "", { deliverAs: "steer" }); sendTo(ws, success("steer")); break; } case "follow_up": { pi.sendUserMessage(command.message || "", { deliverAs: "followUp" }); sendTo(ws, success("follow_up")); break; } case "abort": { if (ctx) ctx.abort(); sendTo(ws, success("abort")); break; } // ─── State ─── case "get_state": { if (!ctx) { sendTo(ws, error("get_state", "No context available")); break; } const model = ctx.model; const state = { model, thinkingLevel: pi.getThinkingLevel(), isStreaming: !ctx.isIdle(), sessionFile: ctx.sessionManager.getSessionFile(), sessionName: pi.getSessionName(), autoCompactionEnabled: true, // Extension can't easily check this }; sendTo(ws, success("get_state", state)); break; } case "get_messages": { if (!ctx) { sendTo(ws, error("get_messages", "No context available")); break; } const entries = ctx.sessionManager.getEntries(); sendTo(ws, success("get_messages", { entries })); break; } // ─── Model ─── case "get_available_models": { if (!ctx) { sendTo(ws, error("get_available_models", "No context available")); break; } const models = await ctx.modelRegistry.getAvailable(); sendTo(ws, success("get_available_models", { models })); break; } case "set_model": { if (!ctx) { sendTo(ws, error("set_model", "No context available")); break; } const models = await ctx.modelRegistry.getAvailable(); const model = (models as ModelCandidate[]).find( (m) => m.provider === command.provider && m.id === command.modelId, ); if (!model) { sendTo(ws, error("set_model", `Model not found: ${command.provider}/${command.modelId}`)); break; } const ok = await pi.setModel(model); if (!ok) { sendTo(ws, error("set_model", "No API key for this model")); break; } sendTo(ws, success("set_model", model)); break; } case "cycle_model": { // Extension API doesn't have cycleModel directly // Workaround: get available models, find current, pick next if (!ctx) { sendTo(ws, success("cycle_model", null)); break; } const availModels = await ctx.modelRegistry.getAvailable(); const currentModel = ctx.model; if (!currentModel || availModels.length <= 1) { sendTo(ws, success("cycle_model", null)); break; } const idx = (availModels as ModelCandidate[]).findIndex( (m) => m.provider === currentModel.provider && m.id === currentModel.id, ); const nextModel = availModels[(idx + 1) % availModels.length]; await pi.setModel(nextModel); sendTo( ws, success("cycle_model", { model: nextModel, thinkingLevel: pi.getThinkingLevel(), }), ); break; } // ─── Thinking ─── case "cycle_thinking_level": { const levels = ["off", "minimal", "low", "medium", "high"]; const current = pi.getThinkingLevel(); const idx = levels.indexOf(current); const next = levels[(idx + 1) % levels.length]; pi.setThinkingLevel(next as BrowserCommand["level"]); sendTo(ws, success("cycle_thinking_level", { level: next })); break; } case "set_thinking_level": { pi.setThinkingLevel(command.level); sendTo(ws, success("set_thinking_level")); break; } // ─── Session ─── case "get_session_stats": { if (!ctx) { sendTo(ws, error("get_session_stats", "No context available")); break; } const usage = ctx.getContextUsage(); const entries = ctx.sessionManager.getEntries(); let userMessages = 0, assistantMessages = 0, toolCalls = 0; for (const e of entries) { if (e.type === "message") { if (e.message?.role === "user") userMessages++; else if (e.message?.role === "assistant") assistantMessages++; else if (e.message?.role === "toolResult") toolCalls++; } } sendTo( ws, success("get_session_stats", { sessionFile: ctx.sessionManager.getSessionFile(), userMessages, assistantMessages, toolCalls, totalMessages: entries.length, tokens: usage ? { input: usage.tokens, total: usage.tokens } : null, }), ); break; } case "set_session_name": { const name = command.name?.trim(); if (!name) { sendTo(ws, error("set_session_name", "Name cannot be empty")); break; } pi.setSessionName(name); sendTo(ws, success("set_session_name")); break; } case "set_auto_compaction": { // Extension can't easily toggle auto-compaction // Just acknowledge sendTo(ws, success("set_auto_compaction")); break; } case "compact": { if (ctx) { // Broadcast compaction start to all clients broadcast({ type: "event", event: { type: "auto_compaction_start" }, }); ctx.compact({ customInstructions: command.customInstructions, onComplete: (result: { summary?: unknown }) => { broadcast({ type: "event", event: { type: "auto_compaction_end", summary: result?.summary, }, }); }, onError: (err: Error) => { broadcast({ type: "event", event: { type: "auto_compaction_end", summary: `Error: ${err.message}`, }, }); }, }); } sendTo(ws, success("compact")); break; } case "export_html": { if (!ctx) { sendTo(ws, error("export_html", "No context available")); break; } try { const sessionFile = ctx.sessionManager.getSessionFile(); if (!sessionFile) throw new Error("No session file to export"); const { execSync } = require("node:child_process"); const args = command.outputPath ? `"${sessionFile}" "${command.outputPath}"` : `"${sessionFile}"`; const output = execSync(`pi --export ${args}`, { cwd: process.cwd(), timeout: 30000, encoding: "utf-8", }); // pi prints the output path const result = output.trim().split("\n").pop() || sessionFile.replace(".jsonl", ".html"); sendTo(ws, success("export_html", { path: result })); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); sendTo(ws, error("export_html", message)); } break; } // ─── Commands & Files ─── // ─── Sync ─── case "mirror_sync_request": { if (ctx) { const snapshot = await buildStateSnapshot(ctx); sendTo(ws, snapshot); } else { sendTo(ws, { type: "mirror_sync", entries: [], model: null }); } break; } // ─── Auth ─── case "get_auth": { sendTo(ws, success("get_auth", { configured: false, enabled: false })); break; } case "set_auth": { sendTo( ws, error( "set_auth", "Authentication is not supported. Use tailscale serve or a reverse proxy with TLS for remote access.", ), ); break; } case "navigate_tree": { if (!latestNavigateTree) { sendTo(ws, error("navigate_tree", "Run /webui first to enable editing")); break; } if (!latestCtx?.isIdle()) { sendTo(ws, error("navigate_tree", "Agent is busy")); break; } const entry = latestCtx.sessionManager.getEntry(command.entryId); if (!entry) { sendTo(ws, error("navigate_tree", "Entry not found")); break; } if (!latestCtx.model) { sendTo(ws, error("navigate_tree", "No model selected")); break; } const result = await latestNavigateTree(entry.id); if (result.cancelled) { sendTo(ws, error("navigate_tree", "Navigation cancelled")); break; } // Send success before snapshot so RPC promise resolves correctly sendTo(ws, success("navigate_tree")); // Broadcast updated state to all connected clients const snapshot = await buildStateSnapshot(latestCtx); broadcast(snapshot); break; } default: { sendTo(ws, error(command.type, `Unknown command: ${command.type}`)); } } } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); sendTo(ws, error(command.type || "unknown", message)); } } // ═══════════════════════════════════════ // Static file server // ═══════════════════════════════════════ function serveStaticFile(req: http.IncomingMessage, res: http.ServerResponse) { let urlPath = req.url || "/"; // Handle API routes if (urlPath.startsWith("/api/")) { handleApiRoute(req, res, urlPath); return; } // Strip query params urlPath = urlPath.split("?")[0]; // Default to index.html if (urlPath === "/") urlPath = "/index.html"; const filePath = path.join(STATIC_DIR, urlPath); // Security: prevent directory traversal if (!filePath.startsWith(STATIC_DIR)) { res.writeHead(403); res.end("Forbidden"); return; } // Check file exists fs.stat(filePath, (err, stats) => { if (err || !stats.isFile()) { res.writeHead(404); res.end("Not Found"); return; } const ext = path.extname(filePath).toLowerCase(); const contentType = MIME_TYPES[ext] || "application/octet-stream"; res.writeHead(200, { "Content-Type": contentType }); fs.createReadStream(filePath).pipe(res); }); } // ═══════════════════════════════════════ // API routes (sessions list, etc.) // ═══════════════════════════════════════ function handleApiRoute(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string) { // CORS headers res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(200); res.end(); return; } if (urlPath === "/api/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ status: "ok", mode: "webui", mirrorUrl, }), ); return; } if (urlPath === "/api/projects" && req.method === "GET") { serveProjectsList(res); return; } if (urlPath === "/api/projects/launch" && req.method === "POST") { let body = ""; req.on("data", (chunk: Buffer) => { body += chunk.toString(); }); req.on("end", () => { try { const { path: projectPath } = JSON.parse(body); if (!projectPath || typeof projectPath !== "string") { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "path required" })); return; } // Resolve ~ in path const resolved = projectPath.startsWith("~") ? path.join(process.env.HOME || "", projectPath.slice(1)) : projectPath; if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Directory not found" })); return; } const { execSync } = require("node:child_process"); const escaped = resolved.replace(/'/g, "'\\''"); execSync( `osascript -e 'tell app "iTerm2" to create window with default profile command "cd '"'"'${escaped}'"'"' && pi"'`, ); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: message })); } }); return; } if (urlPath === "/api/sessions" && req.method === "GET") { serveSessionsList(res); return; } // Full-text search across sessions if (urlPath.startsWith("/api/search") && req.method === "GET") { const searchUrl = new URL(`http://localhost${req.url}`); const q = searchUrl.searchParams.get("q") || ""; serveSearch(res, q); return; } // File browser: list directory if (urlPath === "/api/files" || urlPath.startsWith("/api/files?")) { if (req.method !== "GET") { res.writeHead(405); res.end(); return; } try { const filesUrl = new URL(`http://localhost${req.url}`); const explicitPath = filesUrl.searchParams.get("path"); let dirPath = explicitPath || process.cwd(); if (!explicitPath && latestCtx) { try { const entries = latestCtx.sessionManager.getEntries() as SessionEntry[]; const sessionEntry = entries.find((e) => e.type === "session"); if (typeof sessionEntry?.cwd === "string") dirPath = sessionEntry.cwd; } catch {} } serveFileList(res, dirPath); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: message })); } return; } // File browser: open file natively if (urlPath === "/api/open" && req.method === "POST") { let body = ""; req.on("data", (chunk: Buffer) => { body += chunk.toString(); }); req.on("end", async () => { try { const { filePath: fp } = JSON.parse(body); if (!fp || typeof fp !== "string") { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "filePath required" })); return; } const { execFile } = await import("node:child_process"); execFile("open", [fp], (err) => { if (err) latestCtx?.ui.notify(`Failed to open file: ${err.message}`, "warning"); }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: message })); } }); return; } // Session file endpoint: /api/sessions/:dirName/:file const sessionMatch = urlPath.match(/^\/api\/sessions\/([^/]+)\/([^/]+)$/); if (sessionMatch && req.method === "GET") { serveSessionFile(res, sessionMatch[1], sessionMatch[2]); return; } // RPC proxy — handle via WebSocket command handler if (urlPath === "/api/rpc" && req.method === "POST") { let body = ""; req.on("data", (chunk: Buffer) => { body += chunk.toString(); }); req.on("end", async () => { try { const command = JSON.parse(body) as BrowserCommand; // Create a fake WebSocket-like object to capture the response const responsePromise = new Promise((resolve) => { const fakeWs: WritableSocket = { readyState: WebSocket.OPEN, send: (data: string | Buffer | ArrayBuffer | Buffer[]) => resolve(JSON.parse(data.toString())), }; handleCommand(fakeWs, command); }); const response = await responsePromise; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(response)); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: message })); } }); return; } // Session switch — in mirror mode, this is a no-op (session is controlled by TUI) if (urlPath === "/api/sessions/switch" && req.method === "POST") { res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ success: true, mirror: true, note: "Session switching is controlled by the TUI in mirror mode", }), ); return; } // Memoryd check res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); } // ═══════════════════════════════════════ // Sessions list endpoint // ═══════════════════════════════════════ function getTmuxSessionFiles(): Set { try { const { execSync } = require("node:child_process"); // Get tmux pane PIDs const paneOutput = execSync("tmux list-panes -a -F '#{pane_pid}' 2>/dev/null", { encoding: "utf8", }); const tmuxFiles = new Set(); for (const shellPid of paneOutput.trim().split("\n").filter(Boolean)) { try { // Find Pi (node) processes that are children of tmux shells const children = execSync(`pgrep -P ${shellPid} 2>/dev/null`, { encoding: "utf8", }); for (const pid of children.trim().split("\n").filter(Boolean)) { // Check what .jsonl files this process has open const lsofOut = execSync(`lsof -p ${pid} 2>/dev/null | grep '\\.jsonl'`, { encoding: "utf8", }); for (const line of lsofOut.trim().split("\n").filter(Boolean)) { const match = line.match(/\/.+\.jsonl$/); if (match) tmuxFiles.add(match[0]); } } } catch { /* no match */ } } return tmuxFiles; } catch { return new Set(); } } function serveProjectsList(res: http.ServerResponse) { const projectsDir = process.env.PI_WEB_UI_PROJECTS_DIR; if (!projectsDir) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ projects: [] })); return; } const resolved = projectsDir.startsWith("~") ? path.join(process.env.HOME || "", projectsDir.slice(1)) : projectsDir; if (!fs.existsSync(resolved)) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ projects: [], error: "Directory not found" })); return; } try { const entries = fs.readdirSync(resolved, { withFileTypes: true }); // Build session count + recency map from session history const sessionInfo = new Map(); if (fs.existsSync(SESSIONS_DIR)) { for (const dir of fs.readdirSync(SESSIONS_DIR, { withFileTypes: true, })) { if (!dir.isDirectory()) continue; const decodedPath = dir.name.replace(/^--/, "/").replace(/--$/, "").replace(/-/g, "/"); // Check if this session dir maps to a subdirectory of the projects folder if (!decodedPath.startsWith(`${resolved}/`) && !decodedPath.startsWith(resolved)) continue; const sessionDir = path.join(SESSIONS_DIR, dir.name); const files = fs.readdirSync(sessionDir).filter((f) => f.endsWith(".jsonl")); let lastMtime = 0; for (const f of files) { try { const stat = fs.statSync(path.join(sessionDir, f)); if (stat.mtimeMs > lastMtime) lastMtime = stat.mtimeMs; } catch {} } sessionInfo.set(decodedPath, { count: files.length, lastActive: lastMtime, }); } } const projects = entries .filter((e) => e.isDirectory() && !e.name.startsWith(".")) .map((e) => { const fullPath = path.join(resolved, e.name); const info = sessionInfo.get(fullPath) || { count: 0, lastActive: 0 }; return { name: e.name, path: fullPath, sessionCount: info.count, lastActive: info.lastActive || null, }; }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ projects })); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: message })); } } async function serveSessionsList(res: http.ServerResponse) { try { if (!fs.existsSync(SESSIONS_DIR)) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ projects: [] })); return; } const tmuxFiles = getTmuxSessionFiles(); const readline = await import("node:readline"); const dirEntries = CUSTOM_SESSIONS_DIR ? [{ name: CUSTOM_SESSIONS_GROUP, isDirectory: () => true }] : fs.readdirSync(SESSIONS_DIR, { withFileTypes: true }); const projects: ProjectSessionGroup[] = []; for (const dir of dirEntries) { if (!dir.isDirectory()) continue; const projectDir = CUSTOM_SESSIONS_DIR || path.join(SESSIONS_DIR, dir.name); const files = fs.readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")); const decodedPath = CUSTOM_SESSIONS_DIR ? CUSTOM_SESSIONS_DIR : dir.name.replace(/^--/, "/").replace(/--$/, "").replace(/-/g, "/"); const sessions: SessionListItem[] = []; for (const file of files) { try { const filePath = path.join(projectDir, file); const parsed = await parseSessionFile(filePath, readline); if (parsed) { const stat = fs.statSync(filePath); const isTmux = tmuxFiles.has(filePath); sessions.push({ ...parsed, file, filePath, projectPath: decodedPath, mtime: stat.mtimeMs, ...(isTmux && { tmux: true }), }); } } catch { /* skip */ } } sessions.sort((a, b) => b.mtime - a.mtime); if (sessions.length > 0) { projects.push({ path: decodedPath, dirName: dir.name, sessions }); } } projects.sort((a, b) => { const aTime = a.sessions[0]?.mtime || 0; const bTime = b.sessions[0]?.mtime || 0; return bTime - aTime; }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ projects })); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: message })); } } // ═══════════════════════════════════════ // Session file endpoint // ═══════════════════════════════════════ function serveSessionFile(res: http.ServerResponse, dirName: string, file: string) { const filePath = CUSTOM_SESSIONS_DIR && dirName === CUSTOM_SESSIONS_GROUP ? path.join(CUSTOM_SESSIONS_DIR, file) : path.join(SESSIONS_DIR, dirName, file); if (!fs.existsSync(filePath)) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Session not found" })); return; } const entries: unknown[] = []; const stream = fs.createReadStream(filePath, { encoding: "utf8" }); let buffer = ""; stream.on("data", (chunk: string) => { buffer += chunk; const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.trim()) { try { entries.push(JSON.parse(line)); } catch { /* skip */ } } } }); stream.on("end", () => { if (buffer.trim()) { try { entries.push(JSON.parse(buffer)); } catch { /* skip */ } } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ entries })); }); stream.on("error", (e: Error) => { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }); } // ═══════════════════════════════════════ // Parse session file header // ═══════════════════════════════════════ async function parseSessionFile( filePath: string, readline: typeof import("node:readline"), ): Promise { const stream = fs.createReadStream(filePath, { encoding: "utf8" }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); let header: SessionEntry | null = null; let firstMessage: string | null = null; let sessionName: string | null = null; let userMessageCount = 0; let lineCount = 0; for await (const line of rl) { if (!line.trim()) continue; lineCount++; try { const entry = JSON.parse(line) as SessionEntry; if (entry.type === "session") header = entry; else if (entry.type === "session_info" && entry.name) sessionName = entry.name; else if (entry.type === "message" && entry.message?.role === "user") { userMessageCount++; if (!firstMessage) { const content = entry.message.content; if (typeof content === "string") firstMessage = content.substring(0, 120); else if (Array.isArray(content)) { const tb = content.find( (b): b is TextContentBlock => typeof b === "object" && b !== null && (b as { type?: unknown }).type === "text" && typeof (b as { text?: unknown }).text === "string", ); if (tb) firstMessage = tb.text.substring(0, 120); } } } } catch { /* skip */ } if (lineCount > 50 && firstMessage) break; } rl.close(); stream.destroy(); if (!header?.id) return null; if (userMessageCount <= 1 && lineCount <= 8) return null; // pipe mode return { id: header.id, timestamp: header.timestamp || "", name: sessionName, firstMessage, cwd: header.cwd || null, }; } // ═══════════════════════════════════════ // File browser // ═══════════════════════════════════════ const IGNORED_NAMES = new Set([ "node_modules", ".git", "__pycache__", ".DS_Store", ".Trash", ".next", ".nuxt", "dist", "build", ".cache", ".turbo", "venv", ".venv", "env", ".env.local", ".pi", "coverage", ".nyc_output", ".parcel-cache", ]); function serveFileList(res: http.ServerResponse, dirPath: string) { try { if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not a directory" })); return; } const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const items: FileListItem[] = []; for (const entry of entries) { if (entry.name.startsWith(".") && entry.name !== ".env") continue; if (IGNORED_NAMES.has(entry.name)) continue; try { const fullPath = path.join(dirPath, entry.name); const stat = fs.statSync(fullPath); items.push({ name: entry.name, path: fullPath, isDirectory: entry.isDirectory(), size: entry.isDirectory() ? null : stat.size, mtime: stat.mtimeMs, }); } catch { /* skip inaccessible */ } } // Directories first, then files, both alphabetical items.sort((a, b) => { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; return a.name.localeCompare(b.name); }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ path: dirPath, items })); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: message })); } } // ═══════════════════════════════════════ // Full-text search // ═══════════════════════════════════════ async function serveSearch(res: http.ServerResponse, query: string) { try { if (!query || query.length < 2) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ results: [] })); return; } const q = query.toLowerCase(); const readline = await import("node:readline"); const results: SearchResult[] = []; const MAX_RESULTS = 30; if (!fs.existsSync(SESSIONS_DIR)) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ results: [] })); return; } const dirEntries = CUSTOM_SESSIONS_DIR ? [{ name: CUSTOM_SESSIONS_GROUP, isDirectory: () => true }] : fs.readdirSync(SESSIONS_DIR, { withFileTypes: true }); for (const dir of dirEntries) { if (!dir.isDirectory()) continue; if (results.length >= MAX_RESULTS) break; const projectDir = CUSTOM_SESSIONS_DIR || path.join(SESSIONS_DIR, dir.name); const decodedPath = CUSTOM_SESSIONS_DIR ? CUSTOM_SESSIONS_DIR : dir.name.replace(/^--/, "/").replace(/--$/, "").replace(/-/g, "/"); const files = fs.readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")); for (const file of files) { if (results.length >= MAX_RESULTS) break; try { const filePath = path.join(projectDir, file); const stream = fs.createReadStream(filePath, { encoding: "utf8" }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity, }); let sessionId = ""; let sessionName = ""; let sessionTimestamp = ""; let firstMessage = ""; const matches: SearchMatch[] = []; for await (const line of rl) { if (!line.trim()) continue; try { const entry = JSON.parse(line) as SessionEntry; if (entry.type === "session") { sessionId = entry.id; sessionTimestamp = entry.timestamp || ""; } if (entry.type === "session_info" && entry.name) { sessionName = entry.name; } if (entry.type === "message") { const content = entry.message?.content; let text = ""; if (typeof content === "string") text = content; else if (Array.isArray(content)) { text = content .filter( (b): b is TextContentBlock => typeof b === "object" && b !== null && (b as { type?: unknown }).type === "text" && typeof (b as { text?: unknown }).text === "string", ) .map((b) => b.text) .join(" "); } if (!firstMessage && entry.message?.role === "user" && text) { firstMessage = text.substring(0, 120); } if (text?.toLowerCase().includes(q)) { // Extract a snippet around the match const idx = text.toLowerCase().indexOf(q); const start = Math.max(0, idx - 60); const end = Math.min(text.length, idx + q.length + 60); const snippet = (start > 0 ? "…" : "") + text.substring(start, end) + (end < text.length ? "…" : ""); matches.push({ role: entry.message?.role || "unknown", snippet: snippet.replace(/\n/g, " "), }); if (matches.length >= 3) break; // max 3 matches per session } } } catch { /* skip line */ } } rl.close(); stream.destroy(); if (matches.length > 0) { results.push({ filePath, project: decodedPath, sessionId, sessionName, sessionTimestamp, firstMessage, matches, }); } } catch { /* skip file */ } } } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ results })); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: message })); } } // ═══════════════════════════════════════ // Start server function (reusable) // ═══════════════════════════════════════ function startServer() { if (server) return; // Already running server = http.createServer(serveStaticFile); wss = new WebSocketServer({ noServer: true }); server.on("upgrade", (request, socket, head) => { if (request.url === "/ws") { const activeWss = wss; if (!activeWss) { socket.destroy(); return; } activeWss.handleUpgrade(request, socket, head, (ws) => { activeWss.emit("connection", ws, request); }); } else { socket.destroy(); } }); wss.on("connection", (ws) => { clients.add(ws); updateMirrorStatus(); const aliveWs = ws as AliveWebSocket; aliveWs.isAlive = true; ws.on("pong", () => { aliveWs.isAlive = true; }); // Send initial state sendTo(ws, { type: "state", advancedFeatures: !!latestNavigateTree }); // Immediately send state snapshot if (latestCtx) { buildStateSnapshot(latestCtx).then((snapshot) => { sendTo(ws, snapshot); }); } ws.on("message", (data) => { try { const command = JSON.parse(data.toString()); handleCommand(ws, command); } catch (_e) { sendTo(ws, { type: "error", message: "Invalid client message" }); } }); ws.on("close", () => { clients.delete(ws); updateMirrorStatus(); }); ws.on("error", () => { clients.delete(ws); updateMirrorStatus(); }); }); // Heartbeat keeps mobile/Tailscale sessions alive and removes stale clients. heartbeatTimer = setInterval(() => { let changed = false; for (const client of clients) { if (client.readyState !== WebSocket.OPEN) { clients.delete(client); changed = true; continue; } const aliveClient = client as AliveWebSocket; if (!aliveClient.isAlive) { try { client.terminate(); } catch {} clients.delete(client); changed = true; continue; } aliveClient.isAlive = false; try { client.ping(); } catch {} } if (changed) updateMirrorStatus(); }, 20000); const tryListen = (port: number, maxAttempts = 10) => { const activeServer = server; if (!activeServer) return; activeServer.listen(port, HOST, () => { onListening(port); }); activeServer.once("error", (err: NodeError) => { if (err.code === "EADDRINUSE" && port < PORT + maxAttempts) { latestCtx?.ui.setStatus("webui", `pi-web-ui: trying port ${port + 1}`); activeServer.removeAllListeners("error"); tryListen(port + 1, maxAttempts); } else { latestCtx?.ui.setStatus("webui", ""); latestCtx?.ui.notify(`pi-web-ui failed to start: ${err.message}`, "error"); stopServer(); } }); }; const onListening = (port: number) => { mirrorUrl = `http://${HOST}:${port}`; mirrorStatusBase = `pi-web-ui: ${HOST}:${port}`; updateMirrorStatus(); latestCtx?.ui.notify(`pi-web-ui: ${mirrorUrl}`, "info"); }; tryListen(PORT); } // ═══════════════════════════════════════ // Auto-start on session begin // ═══════════════════════════════════════ pi.on("session_start", async (_event, ctx) => { latestCtx = ctx; if (!AUTO_START) { return; } startServer(); }); // ═══════════════════════════════════════ // Cleanup on shutdown // ═══════════════════════════════════════ pi.on("session_shutdown", async () => { latestCtx = null; stopServer(); }); }