Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
955
extensions/web-chat.ts
Normal file
955
extensions/web-chat.ts
Normal file
@@ -0,0 +1,955 @@
|
||||
// ABOUTME: Web Chat Extension — opens a LAN-accessible chat interface that relays to the main Pi session.
|
||||
// ABOUTME: Phone acts as a thin client — messages are injected into THIS session via pi.sendUserMessage().
|
||||
// ABOUTME: Uses WebSocket for reliable streaming through cloudflared tunnels.
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext, MessageUpdateEvent, ToolExecutionStartEvent, ToolExecutionEndEvent } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import { randomInt } from "node:crypto";
|
||||
import { WebSocketServer, WebSocket as WS } from "ws";
|
||||
import qrTerminal from "qrcode-terminal";
|
||||
import { outputLine } from "./lib/output-box.ts";
|
||||
import { applyExtensionDefaults } from "./lib/themeMap.ts";
|
||||
import { generateWebChatHTML } from "./lib/web-chat-html.ts";
|
||||
import { registerActiveViewer, clearActiveViewer, notifyViewerOpen } from "./lib/viewer-session.ts";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
source?: "phone" | "terminal";
|
||||
toolCalls?: string[];
|
||||
}
|
||||
|
||||
interface WSClient {
|
||||
id: number;
|
||||
ws: WS;
|
||||
}
|
||||
|
||||
// ── LAN IP Detection ─────────────────────────────────────────────────
|
||||
|
||||
function getLanIP(): string {
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name] || []) {
|
||||
if (net.family === "IPv4" && !net.internal) {
|
||||
return net.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
// ── Cloudflare Tunnel ────────────────────────────────────────────────
|
||||
|
||||
function isCloudflaredAvailable(): boolean {
|
||||
try {
|
||||
execSync("which cloudflared", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function startTunnel(localPort: number): Promise<{ url: string; proc: ChildProcess }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("cloudflared", [
|
||||
"tunnel",
|
||||
"--url", `http://127.0.0.1:${localPort}`,
|
||||
], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error("Tunnel failed to start within 15 seconds"));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// cloudflared prints the URL to stderr
|
||||
let stderrBuf = "";
|
||||
proc.stderr!.setEncoding("utf-8");
|
||||
proc.stderr!.on("data", (chunk: string) => {
|
||||
stderrBuf += chunk;
|
||||
const match = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match && !resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({ url: match[0], proc });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`cloudflared exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── PIN Authentication ───────────────────────────────────────────────
|
||||
|
||||
function generatePIN(): string {
|
||||
return String(randomInt(100000, 999999));
|
||||
}
|
||||
|
||||
// ── Logo Loading ─────────────────────────────────────────────────────
|
||||
|
||||
function loadLogoBase64(): string {
|
||||
try {
|
||||
const extDir = dirname(fileURLToPath(import.meta.url));
|
||||
const logoPath = `${extDir}/../agent-logo.png`;
|
||||
if (existsSync(logoPath)) {
|
||||
const buf = readFileSync(logoPath);
|
||||
return `data:image/png;base64,${buf.toString("base64")}`;
|
||||
}
|
||||
} catch {}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── QR Code Generation ───────────────────────────────────────────────
|
||||
|
||||
function generateQRString(url: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
qrTerminal.generate(url, { small: true }, (code: string) => {
|
||||
resolve(code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function printLocalInfo(url: string, pin: string): void {
|
||||
const w = process.stderr.write.bind(process.stderr);
|
||||
w("\n");
|
||||
w(` ${url}\n`);
|
||||
w(` \x1b[1mPIN: ${pin}\x1b[0m\n`);
|
||||
w("\n");
|
||||
}
|
||||
|
||||
// 3-row bitmap font for digits 0-9 (each char is 3 cols wide + 1 space)
|
||||
const BIG_DIGITS: Record<string, string[]> = {
|
||||
"0": ["▄▀▄", "█ █", "▀▄▀"],
|
||||
"1": ["▄█ ", " █ ", "▄█▄"],
|
||||
"2": ["▀▀█", " ▄▀", "█▄▄"],
|
||||
"3": ["▀▀█", " ▀█", "▄▄█"],
|
||||
"4": ["█ █", "▀▀█", " █"],
|
||||
"5": ["█▀▀", "▀▀█", "▄▄█"],
|
||||
"6": ["█▀▀", "█▀█", "▀▄▀"],
|
||||
"7": ["▀▀█", " ▐▌", " █ "],
|
||||
"8": ["▄▀▄", "█▀█", "▀▄▀"],
|
||||
"9": ["▄▀▄", "▀▀█", "▄▄▀"],
|
||||
};
|
||||
|
||||
function renderBigPin(pin: string): string {
|
||||
const rows: string[] = ["", "", ""];
|
||||
for (const ch of pin) {
|
||||
const glyph = BIG_DIGITS[ch];
|
||||
if (!glyph) continue;
|
||||
for (let r = 0; r < 3; r++) {
|
||||
rows[r] += glyph[r] + " ";
|
||||
}
|
||||
}
|
||||
return rows.map((r) => ` ${r}`).join("\n");
|
||||
}
|
||||
|
||||
function printRemoteQRBlock(qr: string, url: string, pin: string): void {
|
||||
const w = process.stderr.write.bind(process.stderr);
|
||||
w("\n\n\n\n\n\n");
|
||||
w(qr);
|
||||
w("\n\n\n\n");
|
||||
w(` ${url}\n\n`);
|
||||
w(` \x1b[1mPIN: ${pin}\x1b[0m\n`);
|
||||
w("\n\n");
|
||||
}
|
||||
|
||||
// ── WebSocket Helpers ────────────────────────────────────────────────
|
||||
|
||||
function sendWS(client: WSClient, event: string, data: any): void {
|
||||
try {
|
||||
if (client.ws.readyState === WS.OPEN) {
|
||||
client.ws.send(JSON.stringify({ event, data }));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function broadcastWS(clients: Map<number, WSClient>, event: string, data: any): void {
|
||||
for (const client of clients.values()) {
|
||||
sendWS(client, event, data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session Bridge (relay to main Pi session) ────────────────────────
|
||||
|
||||
const TERMINAL_BUFFER_MAX = 200;
|
||||
|
||||
class SessionBridge {
|
||||
private piApi: ExtensionAPI;
|
||||
private clients: Map<number, WSClient>;
|
||||
private busy = false;
|
||||
private history: ChatMessage[] = [];
|
||||
private textBuffer: string[] = [];
|
||||
private toolNames: string[] = [];
|
||||
private terminalLines: string[] = [];
|
||||
private pendingFromPhone = false;
|
||||
|
||||
constructor(piApi: ExtensionAPI, clients: Map<number, WSClient>) {
|
||||
this.piApi = piApi;
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
isBusy(): boolean {
|
||||
return this.busy;
|
||||
}
|
||||
|
||||
getHistory(): ChatMessage[] {
|
||||
return this.history;
|
||||
}
|
||||
|
||||
getTerminalHistory(): string[] {
|
||||
return this.terminalLines;
|
||||
}
|
||||
|
||||
hasClients(): boolean {
|
||||
return this.clients.size > 0;
|
||||
}
|
||||
|
||||
pushTerminalLine(line: string): void {
|
||||
this.terminalLines.push(line);
|
||||
if (this.terminalLines.length > TERMINAL_BUFFER_MAX) {
|
||||
this.terminalLines.shift();
|
||||
}
|
||||
broadcastWS(this.clients, "terminal_output", { line });
|
||||
}
|
||||
|
||||
// ── Called from HTTP /send endpoint ──
|
||||
|
||||
sendMessage(text: string): void {
|
||||
if (this.busy) {
|
||||
broadcastWS(this.clients, "error_event", {
|
||||
message: "Agent is busy. Wait for the current response to finish.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Track that this message came from the phone
|
||||
this.pendingFromPhone = true;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "phone",
|
||||
};
|
||||
this.history.push(userMsg);
|
||||
broadcastWS(this.clients, "user_message", userMsg);
|
||||
|
||||
// Inject into main Pi session — this triggers a turn
|
||||
// Use deliverAs: "followUp" so it works even when the agent is busy
|
||||
try {
|
||||
this.piApi.sendUserMessage(text, { deliverAs: "followUp" });
|
||||
} catch (err: any) {
|
||||
broadcastWS(this.clients, "error_event", {
|
||||
message: "Failed to send message: " + (err?.message || "Unknown error"),
|
||||
});
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers (called from pi.on() hooks) ──
|
||||
|
||||
onAgentStart(): void {
|
||||
this.busy = true;
|
||||
this.textBuffer = [];
|
||||
this.toolNames = [];
|
||||
this.pushTerminalLine("[start] Processing...");
|
||||
broadcastWS(this.clients, "status", { busy: true });
|
||||
}
|
||||
|
||||
onAgentEnd(): void {
|
||||
this.busy = false;
|
||||
this.pendingFromPhone = false;
|
||||
broadcastWS(this.clients, "status", { busy: false });
|
||||
}
|
||||
|
||||
onMessageUpdate(event: MessageUpdateEvent): void {
|
||||
const delta = event.assistantMessageEvent;
|
||||
if (!delta) return;
|
||||
|
||||
if (delta.type === "text_delta") {
|
||||
const text = (delta as any).delta || "";
|
||||
this.textBuffer.push(text);
|
||||
broadcastWS(this.clients, "text_delta", { text });
|
||||
} else if (delta.type === "thinking_start") {
|
||||
this.pushTerminalLine("[think] Reasoning...");
|
||||
} else if (delta.type === "text_start") {
|
||||
this.pushTerminalLine("[text] Responding...");
|
||||
}
|
||||
}
|
||||
|
||||
onMessageEnd(message: any): void {
|
||||
// Skip user messages and tool results — only relay assistant responses to the phone.
|
||||
// Without this, the user's own message gets echoed back as a "PI" message,
|
||||
// and tool results get incorrectly displayed as assistant messages.
|
||||
if (message?.role === "user" || message?.role === "toolResult") return;
|
||||
|
||||
// Extract the full text from the completed message
|
||||
let fullText = "";
|
||||
if (message?.content) {
|
||||
if (Array.isArray(message.content)) {
|
||||
fullText = message.content
|
||||
.filter((p: any) => p.type === "text")
|
||||
.map((p: any) => p.text || "")
|
||||
.join("");
|
||||
} else if (typeof message.content === "string") {
|
||||
fullText = message.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fullText) {
|
||||
fullText = this.textBuffer.join("");
|
||||
}
|
||||
|
||||
if (fullText) {
|
||||
const preview = fullText.length > 60 ? fullText.slice(0, 57) + "..." : fullText;
|
||||
this.pushTerminalLine(`[msg] ${preview.replace(/\n/g, " ")}`);
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: fullText,
|
||||
timestamp: new Date().toISOString(),
|
||||
toolCalls: this.toolNames.length > 0 ? [...this.toolNames] : undefined,
|
||||
};
|
||||
this.history.push(assistantMsg);
|
||||
broadcastWS(this.clients, "assistant_message", assistantMsg);
|
||||
}
|
||||
|
||||
// ALWAYS signal completion — matches the working version.
|
||||
// This fires for every message (including tool-use), which resets
|
||||
// the phone's busy state. The phone handles this gracefully.
|
||||
broadcastWS(this.clients, "done", {});
|
||||
broadcastWS(this.clients, "status", { busy: false });
|
||||
this.busy = false;
|
||||
this.textBuffer = [];
|
||||
this.toolNames = [];
|
||||
}
|
||||
|
||||
onToolStart(event: ToolExecutionStartEvent): void {
|
||||
const name = event.toolName || "tool";
|
||||
this.toolNames.push(name);
|
||||
broadcastWS(this.clients, "tool_start", { name });
|
||||
this.pushTerminalLine(`[tool] ${name}`);
|
||||
|
||||
// Detect subagent spawning
|
||||
if (name === "subagent_create" || name === "subagent_create_batch") {
|
||||
const args = event.args;
|
||||
if (name === "subagent_create_batch" && args?.agents) {
|
||||
const count = args.agents.length;
|
||||
const names = args.agents.map((a: any) => a.name || a.summary || "agent").join(", ");
|
||||
this.pushTerminalLine(`[agent] Spawning ${count} agents: ${names}`);
|
||||
broadcastWS(this.clients, "subagent_start", { count, names });
|
||||
} else if (name === "subagent_create") {
|
||||
const agentName = args?.name || args?.summary || "agent";
|
||||
this.pushTerminalLine(`[agent] Spawning: ${agentName}`);
|
||||
broadcastWS(this.clients, "subagent_start", { count: 1, names: agentName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onToolEnd(event: ToolExecutionEndEvent): void {
|
||||
const name = event.toolName || "tool";
|
||||
const ok = !event.isError;
|
||||
broadcastWS(this.clients, "tool_end", {});
|
||||
this.pushTerminalLine(`[${ok ? "ok" : "err"}] ${name}`);
|
||||
}
|
||||
|
||||
onInput(text: string, source: string): void {
|
||||
// Log the input source in terminal feed
|
||||
const label = source === "extension" ? "[phone]" : "[term]";
|
||||
const preview = text.length > 60 ? text.slice(0, 57) + "..." : text;
|
||||
this.pushTerminalLine(`${label} ${preview}`);
|
||||
|
||||
// Capture input from the terminal user (not from phone — we already tracked that)
|
||||
if (source !== "extension" && !this.pendingFromPhone) {
|
||||
const userMsg: ChatMessage = {
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "terminal",
|
||||
};
|
||||
this.history.push(userMsg);
|
||||
broadcastWS(this.clients, "user_message", userMsg);
|
||||
}
|
||||
// Reset the pending flag after input is processed
|
||||
if (this.pendingFromPhone) {
|
||||
this.pendingFromPhone = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.busy = false;
|
||||
this.history = [];
|
||||
this.textBuffer = [];
|
||||
this.toolNames = [];
|
||||
this.terminalLines = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP Server ──────────────────────────────────────────────────────
|
||||
|
||||
function startChatServer(
|
||||
bridge: SessionBridge,
|
||||
pin: string,
|
||||
onShutdown: () => void,
|
||||
): Promise<{ port: number; server: Server }> {
|
||||
return new Promise((resolve) => {
|
||||
const wsClients = bridge["clients"];
|
||||
let clientIdCounter = 0;
|
||||
const logoDataUri = loadLogoBase64();
|
||||
// Single-user lock: only one authenticated session at a time
|
||||
let activeToken: string | null = null;
|
||||
|
||||
function makeToken(): string {
|
||||
// Revoke any previous token — only one user at a time
|
||||
const t = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
activeToken = t;
|
||||
return t;
|
||||
}
|
||||
|
||||
function isAuthed(req: IncomingMessage, url: URL): boolean {
|
||||
if (!activeToken) return false;
|
||||
const cookies = req.headers.cookie || "";
|
||||
const match = cookies.match(/pi_token=([^;]+)/);
|
||||
if (match && match[1] === activeToken) return true;
|
||||
const qToken = url.searchParams.get("token");
|
||||
if (qToken && qToken === activeToken) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-shutdown timer: close server if no clients for 2 minutes
|
||||
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function resetShutdownTimer() {
|
||||
if (shutdownTimer) clearTimeout(shutdownTimer);
|
||||
shutdownTimer = setTimeout(() => {
|
||||
if (wsClients.size === 0) {
|
||||
try { server.close(); } catch {}
|
||||
onShutdown();
|
||||
}
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
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(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || "/", `http://localhost`);
|
||||
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── PIN Auth ─────────────────────────────────────────
|
||||
if (req.method === "POST" && url.pathname === "/auth") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body || "{}");
|
||||
if (String(data.pin) === pin) {
|
||||
const token = makeToken();
|
||||
res.setHeader("Set-Cookie", `pi_token=${token}; Path=/; HttpOnly; SameSite=Strict`);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, token }));
|
||||
} else {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "Invalid PIN" }));
|
||||
}
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "Bad request" }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Chat UI (PIN gate is client-side) ────────────────
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const html = generateWebChatHTML({ port: (server.address() as any)?.port || 0, logoDataUri });
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── All API endpoints require auth ───────────────────
|
||||
if (!isAuthed(req, url)) {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Send Message (relay to main session) ─────────────
|
||||
if (req.method === "POST" && url.pathname === "/send") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body || "{}");
|
||||
const message = String(data.message || "").trim();
|
||||
if (!message) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "Empty message" }));
|
||||
return;
|
||||
}
|
||||
bridge.sendMessage(message);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} catch (err: any) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err?.message || "Invalid request" }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Status ───────────────────────────────────────────
|
||||
if (req.method === "GET" && url.pathname === "/status") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
busy: bridge.isBusy(),
|
||||
historyCount: bridge.getHistory().length,
|
||||
clients: wsClients.size,
|
||||
relay: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Terminal History ──────────────────────────────────
|
||||
if (req.method === "GET" && url.pathname === "/terminal") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ lines: bridge.getTerminalHistory() }));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── History ──────────────────────────────────────────
|
||||
if (req.method === "GET" && url.pathname === "/history") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ messages: bridge.getHistory() }));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Shutdown (explicit close from client) ────────────
|
||||
if (req.method === "POST" && url.pathname === "/shutdown") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
setTimeout(() => {
|
||||
try { server.close(); } catch {}
|
||||
onShutdown();
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
// WebSocket server for streaming
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const url = new URL(req.url || "/", `http://localhost`);
|
||||
if (url.pathname !== "/ws") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
// Validate auth token
|
||||
if (!activeToken) { socket.destroy(); return; }
|
||||
const qToken = url.searchParams.get("token");
|
||||
const cookies = req.headers.cookie || "";
|
||||
const match = cookies.match(/pi_token=([^;]+)/);
|
||||
const cookieToken = match ? match[1] : null;
|
||||
if (qToken !== activeToken && cookieToken !== activeToken) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
resetShutdownTimer();
|
||||
const clientId = ++clientIdCounter;
|
||||
const client: WSClient = { id: clientId, ws };
|
||||
wsClients.set(clientId, client);
|
||||
|
||||
// Send initial state
|
||||
sendWS(client, "connected", {
|
||||
busy: bridge.isBusy(),
|
||||
historyCount: bridge.getHistory().length,
|
||||
relay: true,
|
||||
});
|
||||
|
||||
// Send existing history (exclude tool results - they're internal, not chat messages)
|
||||
for (const msg of bridge.getHistory()) {
|
||||
if (msg.role === "user") {
|
||||
sendWS(client, "user_message", msg);
|
||||
} else if (msg.role === "assistant") {
|
||||
sendWS(client, "assistant_message", msg);
|
||||
}
|
||||
// toolResult messages are intentionally not sent to the web chat
|
||||
}
|
||||
|
||||
// Send existing terminal history
|
||||
if (bridge.getTerminalHistory().length === 0) {
|
||||
sendWS(client, "terminal_output", { line: "[info] Connected — activity will appear here" });
|
||||
}
|
||||
for (const line of bridge.getTerminalHistory()) {
|
||||
sendWS(client, "terminal_output", { line });
|
||||
}
|
||||
|
||||
// Ping to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
try { if (ws.readyState === WS.OPEN) ws.ping(); } catch {}
|
||||
}, 30000);
|
||||
|
||||
ws.on("close", () => {
|
||||
clearInterval(pingInterval);
|
||||
wsClients.delete(clientId);
|
||||
if (wsClients.size === 0) resetShutdownTimer();
|
||||
});
|
||||
|
||||
ws.on("error", () => {
|
||||
clearInterval(pingInterval);
|
||||
wsClients.delete(clientId);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(0, "0.0.0.0", () => {
|
||||
const addr = server.address() as any;
|
||||
resolve({ port: addr.port, server });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Browser Opener ───────────────────────────────────────────────────
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
try {
|
||||
execSync(`open "${url}"`, { stdio: "ignore" });
|
||||
} catch {
|
||||
try {
|
||||
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
|
||||
} catch {
|
||||
try {
|
||||
execSync(`start "${url}"`, { stdio: "ignore" });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool Parameters ──────────────────────────────────────────────────
|
||||
|
||||
const ShowChatParams = Type.Object({
|
||||
port: Type.Optional(Type.Number({ description: "Specific port to use (default: auto-assigned)" })),
|
||||
});
|
||||
|
||||
// ── Extension ────────────────────────────────────────────────────────
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let activeServer: Server | null = null;
|
||||
let activeTunnel: ChildProcess | null = null;
|
||||
let activeTunnelUrl: string | null = null;
|
||||
let activeBridge: SessionBridge | null = null;
|
||||
let activeSession: {
|
||||
kind: "chat";
|
||||
title: string;
|
||||
url: string;
|
||||
server: Server;
|
||||
onClose: () => void;
|
||||
} | null = null;
|
||||
|
||||
function cleanupServer() {
|
||||
// Kill tunnel
|
||||
if (activeTunnel) {
|
||||
try { activeTunnel.kill(); } catch {}
|
||||
activeTunnel = null;
|
||||
activeTunnelUrl = null;
|
||||
}
|
||||
const server = activeServer;
|
||||
activeServer = null;
|
||||
if (server) {
|
||||
try { server.close(); } catch {}
|
||||
}
|
||||
if (activeBridge) {
|
||||
activeBridge.destroy();
|
||||
activeBridge = null;
|
||||
}
|
||||
if (activeSession) {
|
||||
clearActiveViewer(activeSession);
|
||||
activeSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
let currentPIN = "";
|
||||
|
||||
interface LaunchResult {
|
||||
localUrl: string;
|
||||
lanUrl: string;
|
||||
pin: string;
|
||||
tunnelUrl?: string;
|
||||
}
|
||||
|
||||
async function launchChat(ctx: ExtensionContext, remote = false): Promise<LaunchResult> {
|
||||
cleanupServer();
|
||||
|
||||
// Create the session bridge with shared WebSocket client map
|
||||
const wsClients = new Map<number, WSClient>();
|
||||
const bridge = new SessionBridge(pi, wsClients);
|
||||
activeBridge = bridge;
|
||||
|
||||
currentPIN = generatePIN();
|
||||
const { port, server } = await startChatServer(bridge, currentPIN, () => {
|
||||
// Called on auto-shutdown or explicit /shutdown
|
||||
if (activeTunnel) {
|
||||
try { activeTunnel.kill(); } catch {}
|
||||
activeTunnel = null;
|
||||
activeTunnelUrl = null;
|
||||
}
|
||||
activeServer = null;
|
||||
activeBridge = null;
|
||||
if (activeSession) {
|
||||
clearActiveViewer(activeSession);
|
||||
activeSession = null;
|
||||
}
|
||||
});
|
||||
activeServer = server;
|
||||
|
||||
const lanIP = getLanIP();
|
||||
const localUrl = `http://127.0.0.1:${port}`;
|
||||
const lanUrl = `http://${lanIP}:${port}`;
|
||||
|
||||
let tunnelUrl: string | undefined;
|
||||
|
||||
if (remote) {
|
||||
if (!isCloudflaredAvailable()) {
|
||||
throw new Error("cloudflared is not installed. Install it with: brew install cloudflared");
|
||||
}
|
||||
const tunnel = await startTunnel(port);
|
||||
activeTunnel = tunnel.proc;
|
||||
activeTunnelUrl = tunnel.url;
|
||||
tunnelUrl = tunnel.url;
|
||||
|
||||
tunnel.proc.on("close", () => {
|
||||
activeTunnel = null;
|
||||
activeTunnelUrl = null;
|
||||
});
|
||||
}
|
||||
|
||||
activeSession = {
|
||||
kind: "chat",
|
||||
title: "Web Chat",
|
||||
url: tunnelUrl || localUrl,
|
||||
server,
|
||||
onClose: () => {
|
||||
activeServer = null;
|
||||
activeSession = null;
|
||||
},
|
||||
};
|
||||
registerActiveViewer(activeSession);
|
||||
notifyViewerOpen(ctx, activeSession);
|
||||
|
||||
return { localUrl, lanUrl, pin: currentPIN, tunnelUrl };
|
||||
}
|
||||
|
||||
// ── Event hooks — relay main session events to phone ─────────────
|
||||
|
||||
pi.on("agent_start", async () => {
|
||||
if (activeBridge) {
|
||||
activeBridge.onAgentStart();
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("agent_end", async () => {
|
||||
if (activeBridge) {
|
||||
activeBridge.onAgentEnd();
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("message_update", async (event) => {
|
||||
if (activeBridge) {
|
||||
activeBridge.onMessageUpdate(event);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("message_end", async (event) => {
|
||||
if (activeBridge) {
|
||||
activeBridge.onMessageEnd((event as any).message);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("turn_end", async () => {
|
||||
if (activeBridge && activeBridge.isBusy()) {
|
||||
activeBridge.pushTerminalLine("[turn] Turn complete");
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("tool_execution_start", async (event) => {
|
||||
if (activeBridge) {
|
||||
activeBridge.onToolStart(event);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("tool_execution_end", async (event) => {
|
||||
if (activeBridge) {
|
||||
activeBridge.onToolEnd(event);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("input", async (event) => {
|
||||
if (activeBridge) {
|
||||
activeBridge.onInput(event.text, event.source);
|
||||
}
|
||||
});
|
||||
|
||||
// ── show_chat tool ───────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "show_chat",
|
||||
label: "Web Chat",
|
||||
description:
|
||||
"Open a web-based chat interface accessible from your phone or any device on the local network. " +
|
||||
"Starts an HTTP server on 0.0.0.0 (LAN-accessible) with a mobile-friendly chat UI. " +
|
||||
"Messages from the phone are relayed directly into THIS Pi session — same conversation, same tools, same subagents. " +
|
||||
"The server stays running in the background — close it with /chat stop.",
|
||||
parameters: ShowChatParams,
|
||||
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
const { localUrl, lanUrl, pin } = await launchChat(ctx);
|
||||
openBrowser(localUrl);
|
||||
|
||||
printLocalInfo(lanUrl, pin);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: [
|
||||
`Web Chat is live (relay mode)`,
|
||||
``,
|
||||
`Local: ${localUrl}`,
|
||||
`Phone: ${lanUrl}`,
|
||||
`PIN: ${pin}`,
|
||||
``,
|
||||
`Only one device can be authenticated at a time.`,
|
||||
``,
|
||||
` /chat -- reopen/restart the chat`,
|
||||
` /chat --remote -- secure tunnel (accessible from anywhere)`,
|
||||
` /chat stop -- shut down the server`,
|
||||
].join("\n"),
|
||||
}],
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(_args, theme) {
|
||||
const text =
|
||||
theme.fg("toolTitle", theme.bold("show_chat ")) +
|
||||
theme.fg("accent", "Web Chat (relay)");
|
||||
return new Text(outputLine(theme, "accent", text), 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
const text = result.content[0];
|
||||
const firstLine = text?.type === "text" ? text.text.split("\n")[0] : "";
|
||||
return new Text(outputLine(theme, "success", firstLine), 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ── /chat command ────────────────────────────────────────────────
|
||||
|
||||
pi.registerCommand("chat", {
|
||||
description: "Open web chat (relay mode). '/chat --remote' for tunnel, '/chat stop' to shut down",
|
||||
handler: async (args, ctx) => {
|
||||
const trimmed = args.trim().toLowerCase();
|
||||
|
||||
if (trimmed === "stop") {
|
||||
if (activeServer) {
|
||||
const hadTunnel = !!activeTunnel;
|
||||
cleanupServer();
|
||||
ctx.ui.notify(
|
||||
hadTunnel ? "Web chat server and tunnel stopped." : "Web chat server stopped.",
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify("No web chat server is running.", "warning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("/chat requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const remote = trimmed === "--remote" || trimmed === "-r" || trimmed === "remote";
|
||||
|
||||
try {
|
||||
const { localUrl, lanUrl, pin, tunnelUrl } = await launchChat(ctx, remote);
|
||||
openBrowser(localUrl);
|
||||
|
||||
if (remote && tunnelUrl) {
|
||||
const qr = await generateQRString(tunnelUrl);
|
||||
printRemoteQRBlock(qr, tunnelUrl, pin);
|
||||
ctx.ui.notify(`Web Chat → ${tunnelUrl} PIN: ${pin}`, "success");
|
||||
} else {
|
||||
printLocalInfo(lanUrl, pin);
|
||||
ctx.ui.notify(`Web Chat → ${lanUrl} PIN: ${pin}`, "success");
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.ui.notify(err?.message || "Failed to start chat", "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
applyExtensionDefaults(import.meta.url, ctx);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
cleanupServer();
|
||||
});
|
||||
|
||||
// Kill chat server when the terminal/process exits (SIGINT, SIGTERM, etc.)
|
||||
const exitHandler = () => { cleanupServer(); };
|
||||
process.on("exit", exitHandler);
|
||||
process.on("SIGINT", exitHandler);
|
||||
process.on("SIGTERM", exitHandler);
|
||||
}
|
||||
Reference in New Issue
Block a user