diff --git a/README.md b/README.md
index 2ccad3c..a27f1a9 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,12 @@ Website:
PI WEB is a web UI for [Pi Coding Agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) that keeps agent sessions running on your own machine or server. Add your repositories once, open project workspaces and git worktrees, start sessions inside them, and come back later without losing the work. Your browser becomes the cockpit; your server becomes the persistent development environment. Start on your laptop, check in from your phone, and continue from an iPad or another machine whenever that is the device you have at hand.
-
+
+
+
+
+
+
With PI WEB you can:
diff --git a/docs/assets/pi-web-demo.gif b/docs/assets/pi-web-demo.gif
deleted file mode 100644
index 6a21a18..0000000
Binary files a/docs/assets/pi-web-demo.gif and /dev/null differ
diff --git a/docs/assets/pi-web-demo.webm b/docs/assets/pi-web-demo.webm
deleted file mode 100644
index bad4c8a..0000000
Binary files a/docs/assets/pi-web-demo.webm and /dev/null differ
diff --git a/docs/assets/pi-web-desktop.png b/docs/assets/pi-web-desktop.png
new file mode 100644
index 0000000..dbbf2b5
Binary files /dev/null and b/docs/assets/pi-web-desktop.png differ
diff --git a/docs/assets/pi-web-mobile.png b/docs/assets/pi-web-mobile.png
new file mode 100644
index 0000000..407154e
Binary files /dev/null and b/docs/assets/pi-web-mobile.png differ
diff --git a/docs/assets/pi-web-tablet.png b/docs/assets/pi-web-tablet.png
new file mode 100644
index 0000000..66a6932
Binary files /dev/null and b/docs/assets/pi-web-tablet.png differ
diff --git a/docs/index.html b/docs/index.html
index cb4805d..2fb873b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -148,10 +148,23 @@
- Workspaces, sessions, transcripts, terminals — one Pi web UI.
+ Workspaces, sessions, transcripts, files — one Pi web UI on every screen.
Bring your own repositories.
-
+
+
+
+ Desktop: chat beside workspace file preview.
+
+
+
+ Tablet: the same session from a wider touch screen.
+
+
+
+ Mobile: the chat stays readable on the go.
+
+
diff --git a/docs/styles.css b/docs/styles.css
index d622208..95b99d7 100644
--- a/docs/styles.css
+++ b/docs/styles.css
@@ -618,6 +618,44 @@ code .comment,
color: var(--muted);
}
+.demo-gallery {
+ display: grid;
+ grid-template-columns: minmax(0, 1.25fr) minmax(220px, 0.72fr);
+ gap: 18px;
+ padding: 18px;
+}
+
+.demo-shot {
+ display: grid;
+ gap: 10px;
+ align-content: start;
+ margin: 0;
+}
+
+.demo-shot-desktop {
+ grid-row: span 2;
+}
+
+.demo-shot img {
+ overflow: hidden;
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: var(--panel-strong);
+ box-shadow: 0 18px 46px rgba(0, 0, 0, 0.2);
+}
+
+.demo-shot-mobile img {
+ width: min(100%, 250px);
+ margin-inline: auto;
+}
+
+.demo-shot figcaption {
+ color: var(--muted-2);
+ font-size: 0.9rem;
+ line-height: 1.45;
+}
+
.manifesto-section {
padding-top: 46px;
}
@@ -1076,6 +1114,14 @@ html[data-theme="light"] .comment {
.manifesto-lines {
align-content: start;
}
+
+ .demo-gallery {
+ grid-template-columns: 1fr;
+ }
+
+ .demo-shot-desktop {
+ grid-row: auto;
+ }
}
@media (max-width: 820px) {
diff --git a/package.json b/package.json
index eeee526..ac172e4 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"build": "tsc -p tsconfig.build.json && npm run build:plugin-api && npm run build:plugins && vite build",
"build:plugin-api": "tsc -p tsconfig.plugin-api.json",
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
+ "capture:screenshots": "node scripts/capture-screenshots.mjs",
"typecheck": "tsc --noEmit",
"knip": "knip",
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts",
diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs
new file mode 100755
index 0000000..9297f38
--- /dev/null
+++ b/scripts/capture-screenshots.mjs
@@ -0,0 +1,652 @@
+#!/usr/bin/env node
+import { spawn, spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { existsSync } from "node:fs";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:net";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(SCRIPT_DIR, "..");
+const DEFAULT_OUTPUT_DIR = join(REPO_ROOT, "docs", "assets");
+const SESSION_ID = "019ef4c0-0000-7000-8000-000000000001";
+const DEMO_FILE = "docs/assets/pi-web-dev-screenshot.png";
+const DEFAULT_SITE_URL = "https://pi-web.dev/";
+const VIEWPORTS = {
+ desktop: { width: 1440, height: 900, mobile: false },
+ tablet: { width: 1024, height: 768, mobile: false },
+ mobile: { width: 390, height: 844, mobile: true },
+};
+
+const args = parseArgs(process.argv.slice(2));
+const outputDir = resolve(args.outputDir ?? DEFAULT_OUTPUT_DIR);
+const keepTemp = args.keepTemp === true;
+const siteUrl = args.siteUrl ?? DEFAULT_SITE_URL;
+const chromeBin = args.chromeBin ?? process.env["CHROME_BIN"] ?? findChrome();
+
+if (chromeBin === undefined) {
+ fail("Chromium was not found. Install chromium-browser/chromium or set CHROME_BIN=/path/to/chrome.");
+}
+
+const tempRoot = await mkdtemp(join(tmpdir(), "pi-web-screenshots-"));
+const children = new Set();
+let cleanedUp = false;
+
+process.once("SIGINT", () => {
+ void cleanup().finally(() => process.exit(130));
+});
+process.once("SIGTERM", () => {
+ void cleanup().finally(() => process.exit(143));
+});
+
+async function main() {
+ const logsDir = join(tempRoot, "logs");
+ const dataDir = join(tempRoot, "pi-web-data");
+ const configPath = join(tempRoot, "config.json");
+ const sessionDir = join(tempRoot, "sessions");
+ const agentDir = join(tempRoot, "pi-agent");
+ const demoProject = join(tempRoot, "pi-web");
+ const projectsFile = join(dataDir, "projects.json");
+ const socketPath = join(dataDir, "sessiond.sock");
+ await Promise.all([
+ mkdir(logsDir, { recursive: true }),
+ mkdir(sessionDir, { recursive: true }),
+ mkdir(agentDir, { recursive: true }),
+ mkdir(dataDir, { recursive: true }),
+ mkdir(outputDir, { recursive: true }),
+ ]);
+
+ console.log(`Temporary workspace: ${tempRoot}`);
+ await cloneDemoProject(demoProject);
+ await removeLegacyDemoMedia(demoProject);
+
+ const projectId = "pi-web-demo";
+ const workspaceId = createWorkspaceId(projectId, demoProject);
+ await writeJson(projectsFile, {
+ projects: [{ id: projectId, name: "pi-web", path: demoProject, createdAt: new Date().toISOString() }],
+ });
+ await writeJson(configPath, { host: "127.0.0.1", allowedHosts: true });
+ await writeDemoSession(sessionDir, demoProject);
+
+ const apiPort = await getFreePort();
+ const clientPort = await getFreePort();
+ const debugPort = await getFreePort();
+ const env = {
+ ...process.env,
+ PI_WEB_DATA_DIR: dataDir,
+ PI_WEB_CONFIG: configPath,
+ PI_WEB_PROJECTS_FILE: projectsFile,
+ PI_WEB_SESSIOND_SOCKET: socketPath,
+ PI_WEB_HOST: "127.0.0.1",
+ PI_WEB_PORT: String(apiPort),
+ PI_WEB_ALLOWED_HOSTS: "true",
+ PI_CODING_AGENT_DIR: agentDir,
+ PI_CODING_AGENT_SESSION_DIR: sessionDir,
+ PI_OFFLINE: "1",
+ NO_COLOR: "1",
+ };
+
+ const tsxBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
+ const viteBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
+ assertExecutable(tsxBin, "Run npm install before capturing screenshots.");
+ assertExecutable(viteBin, "Run npm install before capturing screenshots.");
+
+ console.log("Starting isolated PI WEB session daemon, API server, and Vite client…");
+ startChild("sessiond", tsxBin, ["src/server/sessiond.ts"], { env, cwd: REPO_ROOT, logsDir });
+ await waitForFile(socketPath, 10_000);
+ startChild("api", tsxBin, ["src/server/index.ts"], { env, cwd: REPO_ROOT, logsDir });
+ await waitForHttp(`http://127.0.0.1:${apiPort}/api/projects`, 15_000);
+ startChild("vite", viteBin, ["--host", "127.0.0.1", "--port", String(clientPort), "--strictPort", "true"], { env, cwd: REPO_ROOT, logsDir });
+ await waitForHttp(`http://127.0.0.1:${clientPort}/`, 30_000);
+
+ console.log("Starting Chromium and capturing screenshots…");
+ const chrome = startChild("chromium", chromeBin, chromeArgs(debugPort, join(tempRoot, "chrome-profile")), { env, cwd: REPO_ROOT, logsDir });
+ await waitForHttp(`http://127.0.0.1:${debugPort}/json/version`, 15_000);
+ const cdp = await openPage(debugPort);
+ try {
+ await cdp.send("Page.enable");
+ await cdp.send("Runtime.enable");
+
+ await captureWebsiteScreenshot(cdp, join(demoProject, DEMO_FILE), siteUrl);
+
+ const appUrl = new URL(`http://127.0.0.1:${clientPort}/`);
+ appUrl.searchParams.set("project", projectId);
+ appUrl.searchParams.set("workspace", workspaceId);
+ appUrl.searchParams.set("session", SESSION_ID);
+ appUrl.searchParams.set("view", "chat");
+
+ await captureDesktop(cdp, appUrl, join(outputDir, "pi-web-desktop.png"));
+ await captureDefaultApp(cdp, appUrl, VIEWPORTS.tablet, join(outputDir, "pi-web-tablet.png"));
+ await captureDefaultApp(cdp, appUrl, VIEWPORTS.mobile, join(outputDir, "pi-web-mobile.png"));
+ } finally {
+ cdp.close();
+ chrome.kill("SIGTERM");
+ }
+
+ console.log(`Wrote ${join(outputDir, "pi-web-desktop.png")}`);
+ console.log(`Wrote ${join(outputDir, "pi-web-tablet.png")}`);
+ console.log(`Wrote ${join(outputDir, "pi-web-mobile.png")}`);
+ if (keepTemp) console.log(`Kept temporary workspace: ${tempRoot}`);
+}
+
+async function captureWebsiteScreenshot(cdp, outputPath, url) {
+ await mkdir(dirname(outputPath), { recursive: true });
+ await setViewport(cdp, { width: 1280, height: 720, mobile: false });
+ try {
+ await navigate(cdp, url, 20_000);
+ await waitForDocumentFonts(cdp);
+ await sleep(3500);
+ await capturePng(cdp, outputPath);
+ } catch (error) {
+ console.warn(`Unable to capture ${url}; using a local fallback image. ${error instanceof Error ? error.message : String(error)}`);
+ const fallback = `data:text/html,${encodeURIComponent(fallbackWebsiteHtml(url))}`;
+ await navigate(cdp, fallback, 10_000);
+ await sleep(300);
+ await capturePng(cdp, outputPath);
+ }
+}
+
+async function captureDesktop(cdp, appUrl, outputPath) {
+ await setViewport(cdp, VIEWPORTS.desktop);
+ await navigate(cdp, appUrl.href, 15_000);
+ await waitForApp(cdp);
+ await selectPreviewImage(cdp);
+ await sleep(500);
+ await capturePng(cdp, outputPath);
+}
+
+async function captureDefaultApp(cdp, appUrl, viewport, outputPath) {
+ await setViewport(cdp, viewport);
+ await navigate(cdp, appUrl.href, 15_000);
+ await waitForApp(cdp);
+ await sleep(700);
+ await capturePng(cdp, outputPath);
+}
+
+async function selectPreviewImage(cdp) {
+ await evaluate(cdp, `(async () => {
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+ const app = document.querySelector("pi-web-app");
+ if (!app) throw new Error("pi-web-app not found");
+ if (typeof app.openWorkspaceTool !== "function" || app.files === undefined) {
+ throw new Error("PI WEB app internals needed for deterministic screenshot setup were not available");
+ }
+ app.openWorkspaceTool("core:workspace.files");
+ await app.updateComplete;
+ await sleep(900);
+ await app.files.refreshFiles();
+ await app.updateComplete;
+ if (app.state?.expandedDirs?.["docs"] === undefined) await app.files.expandDir("docs");
+ await app.updateComplete;
+ if (app.state?.expandedDirs?.["docs/assets"] === undefined) await app.files.expandDir("docs/assets");
+ await app.updateComplete;
+ await app.files.selectFile(${JSON.stringify(DEMO_FILE)});
+ await app.updateComplete;
+
+ const panel = app.shadowRoot?.querySelector("workspace-panel");
+ const root = panel?.shadowRoot;
+ await panel?.updateComplete;
+ const start = Date.now();
+ while (Date.now() - start < 8000) {
+ const image = root?.querySelector(".image-preview img");
+ if (root?.textContent.includes(${JSON.stringify(DEMO_FILE)}) && image instanceof HTMLImageElement && image.complete) return true;
+ await sleep(100);
+ }
+ throw new Error("Timed out waiting for image preview");
+ })()`);
+}
+
+async function waitForApp(cdp) {
+ await evaluate(cdp, `new Promise((resolve, reject) => {
+ const start = Date.now();
+ const visibleText = () => {
+ const app = document.querySelector("pi-web-app");
+ const appRoot = app?.shadowRoot;
+ const chatRoot = appRoot?.querySelector("chat-view")?.shadowRoot;
+ return [appRoot?.textContent ?? "", chatRoot?.textContent ?? ""].join("\\n");
+ };
+ const check = () => {
+ const text = visibleText();
+ if (text.includes("Showing messages") && text.includes("assistant")) {
+ resolve(true);
+ return;
+ }
+ if (Date.now() - start > 15000) {
+ reject(new Error("PI WEB app did not restore the seeded session in time. Visible text: " + visibleText()));
+ return;
+ }
+ setTimeout(check, 100);
+ };
+ check();
+ })`);
+}
+
+async function waitForDocumentFonts(cdp) {
+ try {
+ await evaluate(cdp, `document.fonts?.ready?.then(() => true) ?? true`);
+ } catch {
+ // Font loading is best-effort; screenshots still work with fallback fonts.
+ }
+}
+
+async function navigate(cdp, url, timeoutMs) {
+ const loaded = cdp.waitForEvent("Page.loadEventFired", timeoutMs).catch(() => undefined);
+ await cdp.send("Page.navigate", { url });
+ await loaded;
+}
+
+async function setViewport(cdp, viewport) {
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
+ width: viewport.width,
+ height: viewport.height,
+ deviceScaleFactor: 1,
+ mobile: viewport.mobile,
+ });
+}
+
+async function capturePng(cdp, outputPath) {
+ await mkdir(dirname(outputPath), { recursive: true });
+ const { data } = await cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false });
+ await writeFile(outputPath, Buffer.from(data, "base64"));
+}
+
+async function cloneDemoProject(target) {
+ const result = spawnSync("git", ["clone", "--quiet", "--local", "--no-hardlinks", REPO_ROOT, target], {
+ cwd: REPO_ROOT,
+ encoding: "utf8",
+ });
+ if (result.status !== 0) throw new Error(`git clone failed:\n${result.stderr || result.stdout}`);
+}
+
+async function removeLegacyDemoMedia(projectRoot) {
+ await Promise.all([
+ rm(join(projectRoot, "docs", "assets", "pi-web-demo.gif"), { force: true }),
+ rm(join(projectRoot, "docs", "assets", "pi-web-demo.webm"), { force: true }),
+ rm(join(projectRoot, "docs", "assets", "pi-web-demo-flow.gif"), { force: true }),
+ ]);
+}
+
+async function writeDemoSession(sessionDir, cwd) {
+ const now = new Date();
+ const timestamp = now.toISOString();
+ const file = join(sessionDir, `${timestamp.replaceAll(":", "-")}_${SESSION_ID}.jsonl`);
+ const ms = now.getTime();
+ const entries = [
+ { type: "session", version: 3, id: SESSION_ID, timestamp, cwd },
+ { type: "model_change", id: "10000001", parentId: null, timestamp: iso(ms + 100), provider: "openai-codex", modelId: "gpt-5.5" },
+ { type: "thinking_level_change", id: "10000002", parentId: "10000001", timestamp: iso(ms + 200), thinkingLevel: "off" },
+ {
+ type: "message",
+ id: "10000003",
+ parentId: "10000002",
+ timestamp: iso(ms + 1000),
+ message: {
+ role: "user",
+ content: [{ type: "text", text: "Take a screenshot of https://pi-web.dev, save it under docs/assets, and tell me where I can preview it." }],
+ timestamp: ms + 1000,
+ },
+ },
+ { type: "session_info", id: "10000004", parentId: "10000003", timestamp: iso(ms + 1100), name: "Screenshot pi-web.dev" },
+ {
+ type: "message",
+ id: "10000005",
+ parentId: "10000004",
+ timestamp: iso(ms + 2000),
+ message: {
+ role: "assistant",
+ content: [{
+ type: "toolCall",
+ id: "call_demo_screenshot",
+ name: "bash",
+ arguments: { command: `capture-browser-screenshot https://pi-web.dev ${DEMO_FILE}` },
+ }],
+ api: "openai-codex-responses",
+ provider: "openai-codex",
+ model: "gpt-5.5",
+ usage: zeroUsage(),
+ stopReason: "toolUse",
+ timestamp: ms + 2000,
+ },
+ },
+ {
+ type: "message",
+ id: "10000006",
+ parentId: "10000005",
+ timestamp: iso(ms + 3000),
+ message: {
+ role: "toolResult",
+ toolCallId: "call_demo_screenshot",
+ toolName: "bash",
+ content: [{ type: "text", text: `Saved screenshot to ${DEMO_FILE}` }],
+ isError: false,
+ timestamp: ms + 3000,
+ },
+ },
+ {
+ type: "message",
+ id: "10000007",
+ parentId: "10000006",
+ timestamp: iso(ms + 4000),
+ message: {
+ role: "assistant",
+ content: [{ type: "text", text: `Done — I saved the screenshot at \`${DEMO_FILE}\`. Open the Files panel to preview it.` }],
+ api: "openai-codex-responses",
+ provider: "openai-codex",
+ model: "gpt-5.5",
+ usage: zeroUsage(),
+ stopReason: "stop",
+ timestamp: ms + 4000,
+ },
+ },
+ ];
+ await writeFile(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
+}
+
+function fallbackWebsiteHtml(url) {
+ return `PI WEB
pi-web.dev Fallback screenshot for ${escapeHtml(url)}.
`;
+}
+
+function chromeArgs(debugPort, userDataDir) {
+ return [
+ "--headless=new",
+ `--remote-debugging-port=${debugPort}`,
+ "--remote-debugging-address=127.0.0.1",
+ "--remote-allow-origins=*",
+ `--user-data-dir=${userDataDir}`,
+ "--window-size=1440,900",
+ "--force-device-scale-factor=1",
+ "--hide-scrollbars",
+ "--disable-background-networking",
+ "--disable-dev-shm-usage",
+ "--disable-gpu",
+ "--disable-extensions",
+ "--disable-features=Translate,MediaRouter,OptimizationHints",
+ "--no-default-browser-check",
+ "--no-first-run",
+ "--no-sandbox",
+ "about:blank",
+ ];
+}
+
+function startChild(name, command, childArgs, { env, cwd, logsDir }) {
+ const logPath = join(logsDir, `${name}.log`);
+ const child = spawn(command, childArgs, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
+ children.add(child);
+ const chunks = [];
+ const collect = (chunk) => {
+ chunks.push(Buffer.from(chunk));
+ if (chunks.length > 120) chunks.shift();
+ };
+ child.stdout.on("data", collect);
+ child.stderr.on("data", collect);
+ child.stdout.on("data", (chunk) => appendLog(logPath, chunk));
+ child.stderr.on("data", (chunk) => appendLog(logPath, chunk));
+ child.once("exit", (code, signal) => {
+ children.delete(child);
+ if (!cleanedUp && code !== 0 && signal === null) {
+ const recent = Buffer.concat(chunks).toString("utf8").trim();
+ console.error(`${name} exited with code ${code}. Recent log:\n${recent}`);
+ }
+ });
+ return child;
+}
+
+function appendLog(path, chunk) {
+ void mkdir(dirname(path), { recursive: true })
+ .then(() => writeFile(path, chunk, { flag: "a" }))
+ .catch(() => undefined);
+}
+
+async function cleanup() {
+ if (cleanedUp) return;
+ cleanedUp = true;
+ await Promise.all([...children].map((child) => terminate(child)));
+ if (!keepTemp) await rm(tempRoot, { recursive: true, force: true });
+}
+
+async function terminate(child) {
+ if (child.exitCode !== null || child.signalCode !== null) return;
+ child.kill("SIGTERM");
+ await Promise.race([
+ new Promise((resolve) => child.once("exit", resolve)),
+ sleep(2500).then(() => {
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
+ }),
+ ]);
+}
+
+async function waitForHttp(url, timeoutMs) {
+ const start = Date.now();
+ let lastError;
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const response = await fetch(url);
+ if (response.ok) return;
+ lastError = new Error(`${response.status} ${response.statusText}`);
+ } catch (error) {
+ lastError = error;
+ }
+ await sleep(150);
+ }
+ throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
+}
+
+async function waitForFile(path, timeoutMs) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ if (existsSync(path)) return;
+ await sleep(100);
+ }
+ throw new Error(`Timed out waiting for ${path}`);
+}
+
+async function getFreePort() {
+ return new Promise((resolve, reject) => {
+ const server = createServer();
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ const port = typeof address === "object" && address !== null ? address.port : undefined;
+ server.close(() => {
+ if (port === undefined) reject(new Error("Unable to allocate a port"));
+ else resolve(port);
+ });
+ });
+ server.on("error", reject);
+ });
+}
+
+async function openPage(debugPort) {
+ const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: "PUT" });
+ if (!response.ok) throw new Error(`Unable to create Chromium tab: ${response.status} ${response.statusText}`);
+ const info = await response.json();
+ return CDP.connect(info.webSocketDebuggerUrl);
+}
+
+async function evaluate(cdp, expression) {
+ const response = await cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
+ if (response.exceptionDetails !== undefined) throw new Error(`Browser evaluation failed: ${JSON.stringify(response.exceptionDetails)}`);
+ return response.result?.value;
+}
+
+class CDP {
+ static connect(url) {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(url);
+ const cdp = new CDP(ws);
+ ws.addEventListener("open", () => resolve(cdp), { once: true });
+ ws.addEventListener("error", (event) => reject(event.error ?? new Error("CDP websocket error")), { once: true });
+ });
+ }
+
+ constructor(ws) {
+ this.ws = ws;
+ this.nextId = 1;
+ this.pending = new Map();
+ this.listeners = new Map();
+ ws.addEventListener("message", (event) => this.onMessage(event));
+ ws.addEventListener("close", () => {
+ for (const { reject } of this.pending.values()) reject(new Error("CDP websocket closed"));
+ this.pending.clear();
+ });
+ }
+
+ send(method, params = {}) {
+ const id = this.nextId++;
+ this.ws.send(JSON.stringify({ id, method, params }));
+ return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
+ }
+
+ waitForEvent(method, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ cleanupListener();
+ reject(new Error(`Timed out waiting for ${method}`));
+ }, timeoutMs);
+ const listener = (params) => {
+ cleanupListener();
+ resolve(params);
+ };
+ const cleanupListener = () => {
+ clearTimeout(timer);
+ const listeners = this.listeners.get(method) ?? [];
+ this.listeners.set(method, listeners.filter((candidate) => candidate !== listener));
+ };
+ this.listeners.set(method, [...this.listeners.get(method) ?? [], listener]);
+ });
+ }
+
+ close() {
+ this.ws.close();
+ }
+
+ onMessage(event) {
+ const message = JSON.parse(String(event.data));
+ if (message.id !== undefined) {
+ const pending = this.pending.get(message.id);
+ if (pending === undefined) return;
+ this.pending.delete(message.id);
+ if (message.error !== undefined) pending.reject(new Error(JSON.stringify(message.error)));
+ else pending.resolve(message.result ?? {});
+ return;
+ }
+ if (message.method !== undefined) {
+ for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {});
+ }
+ }
+}
+
+function createWorkspaceId(projectId, path) {
+ return createHash("sha1").update(`${projectId}:${path}`).digest("hex").slice(0, 12);
+}
+
+async function writeJson(path, value) {
+ await mkdir(dirname(path), { recursive: true });
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
+}
+
+function iso(ms) {
+ return new Date(ms).toISOString();
+}
+
+function zeroUsage() {
+ return {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ };
+}
+
+function parseArgs(argv) {
+ const parsed = {};
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === "--help" || arg === "-h") {
+ console.log(`Usage: node scripts/capture-screenshots.mjs [--output-dir docs/assets] [--site-url https://pi-web.dev/] [--keep-temp] [--chrome-bin /path/to/chrome]\n\nCaptures desktop, tablet, and mobile PI WEB screenshots from an isolated temporary instance.`);
+ process.exit(0);
+ }
+ if (arg === "--keep-temp") {
+ parsed.keepTemp = true;
+ continue;
+ }
+ if (arg === "--output-dir") {
+ parsed.outputDir = requireValue(argv, ++i, arg);
+ continue;
+ }
+ if (arg.startsWith("--output-dir=")) {
+ parsed.outputDir = arg.slice("--output-dir=".length);
+ continue;
+ }
+ if (arg === "--site-url") {
+ parsed.siteUrl = requireValue(argv, ++i, arg);
+ continue;
+ }
+ if (arg.startsWith("--site-url=")) {
+ parsed.siteUrl = arg.slice("--site-url=".length);
+ continue;
+ }
+ if (arg === "--chrome-bin") {
+ parsed.chromeBin = requireValue(argv, ++i, arg);
+ continue;
+ }
+ if (arg.startsWith("--chrome-bin=")) {
+ parsed.chromeBin = arg.slice("--chrome-bin=".length);
+ continue;
+ }
+ fail(`Unknown argument: ${arg}`);
+ }
+ return parsed;
+}
+
+function requireValue(argv, index, flag) {
+ const value = argv[index];
+ if (value === undefined || value.startsWith("--")) fail(`${flag} requires a value`);
+ return value;
+}
+
+function findChrome() {
+ return findExecutable(["chromium-browser", "chromium", "google-chrome", "google-chrome-stable"]);
+}
+
+function findExecutable(candidates) {
+ for (const candidate of candidates) {
+ const result = spawnSync("sh", ["-lc", `command -v ${shellQuote(candidate)}`], { encoding: "utf8" });
+ if (result.status === 0) return result.stdout.trim().split("\n")[0];
+ }
+ return undefined;
+}
+
+function shellQuote(value) {
+ return `'${value.replaceAll("'", "'\\''")}'`;
+}
+
+function assertExecutable(path, message) {
+ if (!existsSync(path)) fail(`${path} was not found. ${message}`);
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function escapeHtml(value) {
+ return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
+}
+
+function fail(message) {
+ console.error(message);
+ process.exit(1);
+}
+
+try {
+ await main();
+} finally {
+ await cleanup();
+}