Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

pi/agent/skills/optional/chrome-cdp/scripts/cdp.mjs

Raw
#!/usr/bin/env node

// cdp - lightweight Chrome DevTools Protocol CLI
// Uses raw CDP over WebSocket, no Puppeteer dependency.
// Requires Node 22+ (built-in WebSocket).
//
// Per-tab persistent daemon: page commands go through a daemon that holds
// the CDP session open. Chrome's "Allow debugging" modal fires once per
// daemon (= once per tab). Daemons auto-exit after 20min idle.

import { spawn } from "node:child_process";
import {
	chmodSync,
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	unlinkSync,
	writeFileSync,
} from "node:fs";
import net from "node:net";
import { homedir, tmpdir, userInfo } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

function envInt(name, fallback, { min = 1 } = {}) {
	const raw = process.env[name];
	if (!raw) return fallback;
	const parsed = Number.parseInt(raw, 10);
	return Number.isFinite(parsed) && parsed >= min ? parsed : fallback;
}

const TIMEOUT = envInt("PI_CHROME_CDP_CDP_TIMEOUT_MS", 15000);
const NAVIGATION_TIMEOUT = envInt("PI_CHROME_CDP_NAVIGATION_TIMEOUT_MS", 30000);
const IDLE_TIMEOUT = envInt("PI_CHROME_CDP_IDLE_TIMEOUT_MS", 20 * 60 * 1000);
const DAEMON_CONNECT_RETRIES = envInt(
	"PI_CHROME_CDP_DAEMON_CONNECT_RETRIES",
	60,
);
const DAEMON_CONNECT_DELAY = envInt(
	"PI_CHROME_CDP_DAEMON_CONNECT_DELAY_MS",
	100,
	{ min: 0 },
);
const CLICK_HOLD_MS = envInt("PI_CHROME_CDP_CLICK_HOLD_MS", 16, { min: 0 });
const LOADALL_INTERVAL_MS = envInt("PI_CHROME_CDP_LOADALL_INTERVAL_MS", 500, {
	min: 0,
});
const LOADALL_TIMEOUT_MS = envInt(
	"PI_CHROME_CDP_LOADALL_TIMEOUT_MS",
	5 * 60 * 1000,
);
const MAX_SNAP_LINES = envInt("PI_CHROME_CDP_MAX_SNAP_LINES", 4000, { min: 0 });
const MAX_HTML_CHARS = envInt("PI_CHROME_CDP_MAX_HTML_CHARS", 500000, {
	min: 0,
});
const MAX_NET_ENTRIES = envInt("PI_CHROME_CDP_MAX_NET_ENTRIES", 1000, {
	min: 0,
});
const MAX_BATCH_COMMANDS = envInt("PI_CHROME_CDP_MAX_BATCH_COMMANDS", 100);
const MIN_TARGET_PREFIX_LEN = 8;
const STATE_DIR = initStateDir();
const PAGES_CACHE = join(STATE_DIR, "pages.json");

function safeUserId() {
	if (typeof process.getuid === "function") return String(process.getuid());
	return userInfo().username.replace(/[^A-Za-z0-9_.-]/g, "_");
}

function initStateDir() {
	const dir = process.env.PI_CHROME_CDP_STATE_DIR
		? resolve(process.env.PI_CHROME_CDP_STATE_DIR)
		: join(
				process.env.XDG_RUNTIME_DIR || tmpdir(),
				`pi-chrome-cdp-${safeUserId()}`,
			);
	mkdirSync(dir, { recursive: true, mode: 0o700 });
	try {
		chmodSync(dir, 0o700);
	} catch {}
	return dir;
}

function sockPath(targetId) {
	return join(STATE_DIR, `cdp-${targetId}.sock`);
}

function devtoolsPortFiles() {
	return [
		process.env.PI_CHROME_CDP_DEVTOOLS_ACTIVE_PORT,
		resolve(
			homedir(),
			"Library/Application Support/Google/Chrome/DevToolsActivePort",
		),
		resolve(homedir(), ".config/google-chrome/DevToolsActivePort"),
		resolve(homedir(), ".config/chromium/DevToolsActivePort"),
		resolve(homedir(), ".config/google-chrome-unstable/DevToolsActivePort"),
		resolve(homedir(), ".config/microsoft-edge/DevToolsActivePort"),
	].filter(Boolean);
}

async function getWsUrl() {
	const explicit =
		process.env.PI_CHROME_CDP_WS_URL || process.env.CHROME_DEVTOOLS_WS_URL;
	if (explicit) return explicit;

	const checked = [];
	for (const portFile of devtoolsPortFiles()) {
		if (!existsSync(portFile)) {
			checked.push(`${portFile} (missing)`);
			continue;
		}

		const lines = readFileSync(portFile, "utf8").trim().split("\n");
		if (lines.length < 2) {
			checked.push(`${portFile} (invalid)`);
			continue;
		}

		const port = lines[0];
		try {
			const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
				signal: AbortSignal.timeout(500),
			});
			if (!res.ok) throw new Error(`HTTP ${res.status}`);
			const version = await res.json();
			checked.push(`${portFile} (ok)`);
			return (
				version.webSocketDebuggerUrl || `ws://127.0.0.1:${port}${lines[1]}`
			);
		} catch (e) {
			checked.push(`${portFile} (dead: ${e.message})`);
		}
	}

	throw new Error(
		"No live Chrome DevTools endpoint found. Enable Chrome remote debugging at chrome://inspect/#remote-debugging. " +
			`Checked: ${checked.join(", ")}`,
	);
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function listDaemonSockets() {
	return readdirSync(STATE_DIR)
		.filter((f) => f.startsWith("cdp-") && f.endsWith(".sock"))
		.map((f) => ({
			targetId: f.slice(4, -5),
			socketPath: join(STATE_DIR, f),
		}));
}

function resolvePrefix(prefix, candidates, noun = "target", missingHint = "") {
	const upper = prefix.toUpperCase();
	const matches = candidates.filter((candidate) =>
		candidate.toUpperCase().startsWith(upper),
	);
	if (matches.length === 0) {
		const hint = missingHint ? ` ${missingHint}` : "";
		throw new Error(`No ${noun} matching prefix "${prefix}".${hint}`);
	}
	if (matches.length > 1) {
		throw new Error(
			`Ambiguous prefix "${prefix}" — matches ${matches.length} ${noun}s. Use more characters.`,
		);
	}
	return matches[0];
}

function getDisplayPrefixLength(targetIds) {
	if (targetIds.length === 0) return MIN_TARGET_PREFIX_LEN;
	const maxLen = Math.max(...targetIds.map((id) => id.length));
	for (let len = MIN_TARGET_PREFIX_LEN; len <= maxLen; len++) {
		const prefixes = new Set(
			targetIds.map((id) => id.slice(0, len).toUpperCase()),
		);
		if (prefixes.size === targetIds.length) return len;
	}
	return maxLen;
}

function limitTextMiddle(text, maxChars, label = "output") {
	if (!maxChars || text.length <= maxChars) return text;
	const marker = `
… (${label} truncated: ${text.length} chars > ${maxChars}; set limit env to 0 to disable)
`;
	const keep = Math.max(0, maxChars - marker.length);
	const head = Math.ceil(keep * 0.7);
	const tail = Math.floor(keep * 0.3);
	return text.slice(0, head) + marker + text.slice(text.length - tail);
}

function limitLinesMiddle(lines, maxLines, label = "output") {
	if (!maxLines || lines.length <= maxLines) return lines;
	const marker = `… (${label} truncated: ${lines.length} lines > ${maxLines}; set limit env to 0 to disable)`;
	const keep = Math.max(0, maxLines - 1);
	const head = Math.ceil(keep * 0.7);
	const tail = Math.floor(keep * 0.3);
	return [...lines.slice(0, head), marker, ...lines.slice(lines.length - tail)];
}

// ---------------------------------------------------------------------------
// CDP WebSocket client
// ---------------------------------------------------------------------------

class CDP {
	#ws;
	#id = 0;
	#pending = new Map();
	#eventHandlers = new Map();
	#closeHandlers = [];

	async connect(wsUrl) {
		return new Promise((res, rej) => {
			this.#ws = new WebSocket(wsUrl);
			this.#ws.onopen = () => res();
			this.#ws.onerror = (e) =>
				rej(new Error("WebSocket error: " + (e.message || e.type)));
			this.#ws.onclose = () => {
				for (const { reject } of this.#pending.values())
					reject(new Error("WebSocket closed"));
				this.#pending.clear();
				this.#closeHandlers.forEach((h) => h());
			};
			this.#ws.onmessage = (ev) => {
				const msg = JSON.parse(ev.data);
				if (msg.id && this.#pending.has(msg.id)) {
					const { resolve, reject } = this.#pending.get(msg.id);
					this.#pending.delete(msg.id);
					if (msg.error) reject(new Error(msg.error.message));
					else resolve(msg.result);
				} else if (msg.method && this.#eventHandlers.has(msg.method)) {
					for (const handler of [...this.#eventHandlers.get(msg.method)]) {
						handler(msg.params || {}, msg);
					}
				}
			};
		});
	}

	send(method, params = {}, sessionId) {
		const id = ++this.#id;
		return new Promise((resolve, reject) => {
			const timer = setTimeout(() => {
				if (!this.#pending.has(id)) return;
				this.#pending.delete(id);
				reject(new Error(`Timeout: ${method}`));
			}, TIMEOUT);
			this.#pending.set(id, {
				resolve: (value) => {
					clearTimeout(timer);
					resolve(value);
				},
				reject: (error) => {
					clearTimeout(timer);
					reject(error);
				},
			});
			const msg = { id, method, params };
			if (sessionId) msg.sessionId = sessionId;
			this.#ws.send(JSON.stringify(msg));
		});
	}

	onEvent(method, handler) {
		if (!this.#eventHandlers.has(method))
			this.#eventHandlers.set(method, new Set());
		const handlers = this.#eventHandlers.get(method);
		handlers.add(handler);
		return () => {
			handlers.delete(handler);
			if (handlers.size === 0) this.#eventHandlers.delete(method);
		};
	}

	waitForEvent(method, timeout = TIMEOUT) {
		let settled = false;
		let off;
		let timer;
		const promise = new Promise((resolve, reject) => {
			off = this.onEvent(method, (params) => {
				if (settled) return;
				settled = true;
				clearTimeout(timer);
				off();
				resolve(params);
			});
			timer = setTimeout(() => {
				if (settled) return;
				settled = true;
				off();
				reject(new Error(`Timeout waiting for event: ${method}`));
			}, timeout);
		});
		return {
			promise,
			cancel() {
				if (settled) return;
				settled = true;
				clearTimeout(timer);
				off?.();
			},
		};
	}

	onClose(handler) {
		this.#closeHandlers.push(handler);
	}
	close() {
		this.#ws.close();
	}
}

// ---------------------------------------------------------------------------
// Command implementations — return strings, take (cdp, sessionId)
// ---------------------------------------------------------------------------

async function getPages(cdp) {
	const { targetInfos } = await cdp.send("Target.getTargets");
	return targetInfos.filter(
		(t) => t.type === "page" && !t.url.startsWith("chrome://"),
	);
}

function formatPageList(pages) {
	const prefixLen = getDisplayPrefixLength(pages.map((p) => p.targetId));
	return pages
		.map((p) => {
			const id = p.targetId.slice(0, prefixLen).padEnd(prefixLen);
			const title = p.title.substring(0, 54).padEnd(54);
			return `${id}  ${title}  ${p.url}`;
		})
		.join("\n");
}

function shouldShowAxNode(node, compact = false) {
	const role = node.role?.value || "";
	const name = node.name?.value ?? "";
	const value = node.value?.value;
	if (compact && role === "InlineTextBox") return false;
	return (
		role !== "none" &&
		role !== "generic" &&
		!(name === "" && (value === "" || value == null))
	);
}

function formatAxNode(node, depth) {
	const role = node.role?.value || "";
	const name = node.name?.value ?? "";
	const value = node.value?.value;
	const indent = "  ".repeat(Math.min(depth, 10));
	let line = `${indent}[${role}]`;
	if (name !== "") line += ` ${name}`;
	if (!(value === "" || value == null)) line += ` = ${JSON.stringify(value)}`;
	return line;
}

function orderedAxChildren(node, nodesById, childrenByParent) {
	const children = [];
	const seen = new Set();
	for (const childId of node.childIds || []) {
		const child = nodesById.get(childId);
		if (child && !seen.has(child.nodeId)) {
			seen.add(child.nodeId);
			children.push(child);
		}
	}
	for (const child of childrenByParent.get(node.nodeId) || []) {
		if (!seen.has(child.nodeId)) {
			seen.add(child.nodeId);
			children.push(child);
		}
	}
	return children;
}

async function snapshotStr(cdp, sid, compact = false) {
	const { nodes } = await cdp.send("Accessibility.getFullAXTree", {}, sid);
	const nodesById = new Map(nodes.map((node) => [node.nodeId, node]));
	const childrenByParent = new Map();
	for (const node of nodes) {
		if (!node.parentId) continue;
		if (!childrenByParent.has(node.parentId))
			childrenByParent.set(node.parentId, []);
		childrenByParent.get(node.parentId).push(node);
	}

	const lines = [];
	const visited = new Set();
	function visit(node, depth) {
		if (!node || visited.has(node.nodeId)) return;
		visited.add(node.nodeId);
		if (shouldShowAxNode(node, compact)) lines.push(formatAxNode(node, depth));
		for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
			visit(child, depth + 1);
		}
	}

	const roots = nodes.filter(
		(node) => !node.parentId || !nodesById.has(node.parentId),
	);
	for (const root of roots) visit(root, 0);
	for (const node of nodes) visit(node, 0);

	return limitLinesMiddle(lines, MAX_SNAP_LINES, "accessibility snapshot").join(
		"\n",
	);
}

async function evalStr(cdp, sid, expression) {
	await cdp.send("Runtime.enable", {}, sid);
	const result = await cdp.send(
		"Runtime.evaluate",
		{
			expression,
			returnByValue: true,
			awaitPromise: true,
		},
		sid,
	);
	if (result.exceptionDetails) {
		throw new Error(
			result.exceptionDetails.text ||
				result.exceptionDetails.exception?.description,
		);
	}
	const val = result.result.value;
	if (val == null) return "";
	return typeof val === "object" ? JSON.stringify(val, null, 2) : String(val);
}

async function shotStr(cdp, sid, filePath) {
	// Get device scale factor so we can report coordinate mapping.
	let dpr = 1;
	try {
		const raw = await evalStr(cdp, sid, "window.devicePixelRatio");
		const parsed = parseFloat(raw);
		if (parsed > 0) dpr = parsed;
	} catch {
		try {
			const metrics = await cdp.send("Page.getLayoutMetrics", {}, sid);
			if (
				metrics.visualViewport?.clientWidth &&
				metrics.cssVisualViewport?.clientWidth
			) {
				dpr =
					Math.round(
						(metrics.visualViewport.clientWidth /
							metrics.cssVisualViewport.clientWidth) *
							100,
					) / 100;
			}
		} catch {}
	}

	const { data } = await cdp.send(
		"Page.captureScreenshot",
		{ format: "png" },
		sid,
	);
	const out = filePath || join(STATE_DIR, "screenshot.png");
	writeFileSync(out, Buffer.from(data, "base64"));

	const lines = [out];
	lines.push(`Screenshot saved. Device pixel ratio (DPR): ${dpr}`);
	lines.push(`Coordinate mapping:`);
	lines.push(
		`  Screenshot pixels → CSS pixels (for CDP Input events): divide by ${dpr}`,
	);
	lines.push(
		`  e.g. screenshot point (${Math.round(100 * dpr)}, ${Math.round(200 * dpr)}) → CSS (100, 200) → use clickxy <target> 100 200`,
	);
	if (dpr !== 1) {
		lines.push(
			`  On this ${dpr}x display: CSS px = screenshot px / ${dpr} ≈ screenshot px × ${Math.round(100 / dpr) / 100}`,
		);
	}
	return lines.join("\n");
}

async function htmlStr(cdp, sid, selector) {
	const expr = selector
		? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || 'Element not found'`
		: `document.documentElement.outerHTML`;
	return limitTextMiddle(
		await evalStr(cdp, sid, expr),
		MAX_HTML_CHARS,
		selector ? "element HTML" : "page HTML",
	);
}

async function waitForDocumentReady(cdp, sid, timeoutMs = NAVIGATION_TIMEOUT) {
	const deadline = Date.now() + timeoutMs;
	let lastState = "";
	let lastError;
	while (Date.now() < deadline) {
		try {
			const state = await evalStr(cdp, sid, "document.readyState");
			lastState = state;
			if (state === "complete") return;
		} catch (e) {
			lastError = e;
		}
		await sleep(200);
	}

	if (lastState) {
		throw new Error(
			`Timed out waiting for navigation to finish (last readyState: ${lastState})`,
		);
	}
	if (lastError) {
		throw new Error(
			`Timed out waiting for navigation to finish (${lastError.message})`,
		);
	}
	throw new Error("Timed out waiting for navigation to finish");
}

async function navStr(cdp, sid, url) {
	await cdp.send("Page.enable", {}, sid);
	const loadEvent = cdp.waitForEvent("Page.loadEventFired", NAVIGATION_TIMEOUT);
	const result = await cdp.send("Page.navigate", { url }, sid);
	if (result.errorText) {
		loadEvent.cancel();
		throw new Error(result.errorText);
	}
	if (result.loaderId) {
		await loadEvent.promise;
	} else {
		loadEvent.cancel();
	}
	await waitForDocumentReady(cdp, sid, 5000);
	return `Navigated to ${url}`;
}

async function netStr(cdp, sid) {
	const raw = await evalStr(
		cdp,
		sid,
		`JSON.stringify(performance.getEntriesByType('resource').map(e => ({
    name: e.name.substring(0, 120), type: e.initiatorType,
    duration: Math.round(e.duration), size: e.transferSize
  })))`,
	);
	const entries = JSON.parse(raw);
	const shown = MAX_NET_ENTRIES ? entries.slice(0, MAX_NET_ENTRIES) : entries;
	const lines = shown.map(
		(e) =>
			`${String(e.duration).padStart(5)}ms  ${String(e.size || "?").padStart(8)}B  ${(e.type || "?").padEnd(8)}  ${e.name}`,
	);
	if (MAX_NET_ENTRIES && entries.length > shown.length) {
		lines.push(
			`… (network entries truncated: ${entries.length} entries > ${MAX_NET_ENTRIES}; set PI_CHROME_CDP_MAX_NET_ENTRIES=0 to disable)`,
		);
	}
	return lines.join("\n");
}

// Click element by CSS selector
async function clickStr(cdp, sid, selector) {
	if (!selector) throw new Error("CSS selector required");
	const expr = `
    (function() {
      const el = document.querySelector(${JSON.stringify(selector)});
      if (!el) return { ok: false, error: 'Element not found: ' + ${JSON.stringify(selector)} };
      el.scrollIntoView({ block: 'center' });
      el.click();
      return { ok: true, tag: el.tagName, text: el.textContent.trim().substring(0, 80) };
    })()
  `;
	const result = await evalStr(cdp, sid, expr);
	const r = JSON.parse(result);
	if (!r.ok) throw new Error(r.error);
	return `Clicked <${r.tag}> "${r.text}"`;
}

// Click at CSS pixel coordinates using Input.dispatchMouseEvent
async function clickXyStr(cdp, sid, x, y) {
	const cx = parseFloat(x);
	const cy = parseFloat(y);
	if (!Number.isFinite(cx) || !Number.isFinite(cy))
		throw new Error("x and y must be finite numbers (CSS pixels)");
	const base = { x: cx, y: cy, button: "left", clickCount: 1, modifiers: 0 };
	await cdp.send(
		"Input.dispatchMouseEvent",
		{ ...base, type: "mouseMoved" },
		sid,
	);
	await cdp.send(
		"Input.dispatchMouseEvent",
		{ ...base, type: "mousePressed" },
		sid,
	);
	if (CLICK_HOLD_MS > 0) await sleep(CLICK_HOLD_MS);
	await cdp.send(
		"Input.dispatchMouseEvent",
		{ ...base, type: "mouseReleased" },
		sid,
	);
	return `Clicked at CSS (${cx}, ${cy})`;
}

// Type text using Input.insertText (works in cross-origin iframes, unlike eval)
async function typeStr(cdp, sid, text) {
	if (text == null || text === "") throw new Error("text required");
	await cdp.send("Input.insertText", { text }, sid);
	return `Typed ${text.length} characters`;
}

// Load-more: repeatedly click a button/selector until it disappears
async function loadAllStr(
	cdp,
	sid,
	selector,
	intervalMs = LOADALL_INTERVAL_MS,
) {
	if (!selector) throw new Error("CSS selector required");
	let clicks = 0;
	const deadline = Date.now() + LOADALL_TIMEOUT_MS;
	while (Date.now() < deadline) {
		const clickExpr = `
      (function() {
        const el = document.querySelector(${JSON.stringify(selector)});
        if (!el) return { exists: false, clicked: false };
        el.scrollIntoView({ block: 'center' });
        el.click();
        return { exists: true, clicked: true };
      })()
    `;
		const raw = await evalStr(cdp, sid, clickExpr);
		const result = JSON.parse(raw);
		if (!result.exists || !result.clicked) break;
		clicks++;
		if (intervalMs > 0) await sleep(intervalMs);
	}
	return `Clicked "${selector}" ${clicks} time(s) until it disappeared`;
}

// Send a raw CDP command and return the result as JSON
async function evalRawStr(cdp, sid, method, paramsJson) {
	if (!method) throw new Error('CDP method required (e.g. "DOM.getDocument")');
	let params = {};
	if (paramsJson) {
		try {
			params = JSON.parse(paramsJson);
		} catch {
			throw new Error(`Invalid JSON params: ${paramsJson}`);
		}
	}
	const result = await cdp.send(method, params, sid);
	return JSON.stringify(result, null, 2);
}

// ---------------------------------------------------------------------------
// Per-tab daemon
// ---------------------------------------------------------------------------

async function runDaemon(targetId) {
	const sp = sockPath(targetId);

	const cdp = new CDP();
	try {
		await cdp.connect(await getWsUrl());
	} catch (e) {
		process.stderr.write(`Daemon: cannot connect to Chrome: ${e.message}\n`);
		process.exit(1);
	}

	let sessionId;
	try {
		const res = await cdp.send("Target.attachToTarget", {
			targetId,
			flatten: true,
		});
		sessionId = res.sessionId;
	} catch (e) {
		process.stderr.write(`Daemon: attach failed: ${e.message}\n`);
		cdp.close();
		process.exit(1);
	}

	// Shutdown helpers
	let alive = true;
	function shutdown() {
		if (!alive) return;
		alive = false;
		server.close();
		try {
			unlinkSync(sp);
		} catch {}
		cdp.close();
		process.exit(0);
	}

	// Exit if target goes away or Chrome disconnects
	cdp.onEvent("Target.targetDestroyed", (params) => {
		if (params.targetId === targetId) shutdown();
	});
	cdp.onEvent("Target.detachedFromTarget", (params) => {
		if (params.sessionId === sessionId) shutdown();
	});
	cdp.onClose(() => shutdown());
	process.on("SIGTERM", shutdown);
	process.on("SIGINT", shutdown);

	// Idle timer
	let idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
	function resetIdle() {
		clearTimeout(idleTimer);
		idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
	}

	// Handle a command
	async function handleCommand({ cmd, args = [] }) {
		resetIdle();
		try {
			let result;
			switch (cmd) {
				case "list": {
					const pages = await getPages(cdp);
					result = formatPageList(pages);
					break;
				}
				case "list_raw": {
					const pages = await getPages(cdp);
					result = JSON.stringify(pages);
					break;
				}
				case "batch": {
					let commands;
					try {
						commands = JSON.parse(args[0] || "[]");
					} catch {
						throw new Error("Invalid batch JSON");
					}
					if (!Array.isArray(commands))
						throw new Error("Batch JSON must be an array");
					if (commands.length > MAX_BATCH_COMMANDS)
						throw new Error(
							`Batch too large: ${commands.length} > ${MAX_BATCH_COMMANDS}`,
						);
					const disallowed = new Set(["batch", "list", "list_raw", "stop"]);
					const results = [];
					for (const item of commands) {
						if (!item || typeof item.cmd !== "string") {
							results.push({ ok: false, error: "Batch item missing cmd" });
							continue;
						}
						if (disallowed.has(item.cmd)) {
							results.push({
								ok: false,
								error: `Command not allowed in batch: ${item.cmd}`,
							});
							continue;
						}
						results.push(
							await handleCommand({
								cmd: item.cmd,
								args: Array.isArray(item.args) ? item.args : [],
							}),
						);
					}
					const failed = results.find((item) => !item.ok);
					if (failed) throw new Error(`Batch failed: ${failed.error}`);
					result = JSON.stringify(results, null, 2);
					break;
				}
				case "snap":
				case "snapshot":
					result = await snapshotStr(cdp, sessionId, true);
					break;
				case "eval":
					result = await evalStr(cdp, sessionId, args[0]);
					break;
				case "shot":
				case "screenshot":
					result = await shotStr(cdp, sessionId, args[0]);
					break;
				case "html":
					result = await htmlStr(cdp, sessionId, args[0]);
					break;
				case "nav":
				case "navigate":
					result = await navStr(cdp, sessionId, args[0]);
					break;
				case "net":
				case "network":
					result = await netStr(cdp, sessionId);
					break;
				case "click":
					result = await clickStr(cdp, sessionId, args[0]);
					break;
				case "clickxy":
					result = await clickXyStr(cdp, sessionId, args[0], args[1]);
					break;
				case "type":
					result = await typeStr(cdp, sessionId, args[0]);
					break;
				case "loadall": {
					const interval = args[1] ? parseInt(args[1], 10) : undefined;
					if (args[1] && !Number.isFinite(interval))
						throw new Error("interval must be a finite number");
					result = await loadAllStr(cdp, sessionId, args[0], interval);
					break;
				}
				case "evalraw":
					result = await evalRawStr(cdp, sessionId, args[0], args[1]);
					break;
				case "stop":
					return { ok: true, result: "", stopAfter: true };
				default:
					return { ok: false, error: `Unknown command: ${cmd}` };
			}
			return { ok: true, result: result ?? "" };
		} catch (e) {
			return { ok: false, error: e.message };
		}
	}

	// Unix socket server — NDJSON protocol
	// Wire format: each message is one JSON object followed by \n (newline-delimited JSON).
	// Request:  { "id": <number>, "cmd": "<command>", "args": ["arg1", "arg2", ...] }
	// Response: { "id": <number>, "ok": <boolean>, "result": "<string>" }
	//           or { "id": <number>, "ok": false, "error": "<message>" }
	const server = net.createServer((conn) => {
		let buf = "";
		conn.on("data", (chunk) => {
			buf += chunk.toString();
			const lines = buf.split("\n");
			buf = lines.pop(); // keep incomplete last line
			for (const line of lines) {
				if (!line.trim()) continue;
				let req;
				try {
					req = JSON.parse(line);
				} catch {
					conn.write(
						JSON.stringify({
							ok: false,
							error: "Invalid JSON request",
							id: null,
						}) + "\n",
					);
					continue;
				}
				handleCommand(req).then((res) => {
					const payload = JSON.stringify({ ...res, id: req.id }) + "\n";
					if (res.stopAfter) conn.end(payload, shutdown);
					else conn.write(payload);
				});
			}
		});
	});

	try {
		unlinkSync(sp);
	} catch {}
	server.listen(sp, () => {
		try {
			chmodSync(sp, 0o600);
		} catch {}
	});
}

// ---------------------------------------------------------------------------
// CLI ↔ daemon communication
// ---------------------------------------------------------------------------

function connectToSocket(sp) {
	return new Promise((resolve, reject) => {
		const conn = net.connect(sp);
		conn.on("connect", () => resolve(conn));
		conn.on("error", reject);
	});
}

async function getOrStartTabDaemon(targetId) {
	const sp = sockPath(targetId);
	// Try existing daemon
	try {
		return await connectToSocket(sp);
	} catch {}

	// Clean stale socket
	try {
		unlinkSync(sp);
	} catch {}

	// Spawn daemon
	const child = spawn(
		process.execPath,
		[process.argv[1], "_daemon", targetId],
		{
			detached: true,
			stdio: "ignore",
		},
	);
	child.unref();

	// Wait for socket (includes time for user to click Allow)
	for (let i = 0; i <= DAEMON_CONNECT_RETRIES; i++) {
		if (i > 0) await sleep(DAEMON_CONNECT_DELAY);
		try {
			return await connectToSocket(sp);
		} catch {}
	}
	throw new Error("Daemon failed to start — did you click Allow in Chrome?");
}

function sendCommand(conn, req) {
	return new Promise((resolve, reject) => {
		let buf = "";
		let settled = false;

		const cleanup = () => {
			conn.off("data", onData);
			conn.off("error", onError);
			conn.off("end", onEnd);
			conn.off("close", onClose);
		};

		const onData = (chunk) => {
			buf += chunk.toString();
			const idx = buf.indexOf("\n");
			if (idx === -1) return;
			settled = true;
			cleanup();
			resolve(JSON.parse(buf.slice(0, idx)));
			conn.end();
		};

		const onError = (error) => {
			if (settled) return;
			settled = true;
			cleanup();
			reject(error);
		};

		const onEnd = () => {
			if (settled) return;
			settled = true;
			cleanup();
			reject(new Error("Connection closed before response"));
		};

		const onClose = () => {
			if (settled) return;
			settled = true;
			cleanup();
			reject(new Error("Connection closed before response"));
		};

		conn.on("data", onData);
		conn.on("error", onError);
		conn.on("end", onEnd);
		conn.on("close", onClose);
		req.id = 1;
		conn.write(JSON.stringify(req) + "\n");
	});
}

// Find any running daemon socket to reuse for list
function findAnyDaemonSocket() {
	return listDaemonSockets()[0]?.socketPath || null;
}

// ---------------------------------------------------------------------------
// Stop daemons
// ---------------------------------------------------------------------------

async function stopDaemons(targetPrefix) {
	const daemons = listDaemonSockets();

	if (targetPrefix) {
		const targetId = resolvePrefix(
			targetPrefix,
			daemons.map((d) => d.targetId),
			"daemon",
		);
		const daemon = daemons.find((d) => d.targetId === targetId);
		try {
			const conn = await connectToSocket(daemon.socketPath);
			await sendCommand(conn, { cmd: "stop" });
		} catch {
			try {
				unlinkSync(daemon.socketPath);
			} catch {}
		}
		return;
	}

	for (const daemon of daemons) {
		try {
			const conn = await connectToSocket(daemon.socketPath);
			await sendCommand(conn, { cmd: "stop" });
		} catch {
			try {
				unlinkSync(daemon.socketPath);
			} catch {}
		}
	}
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

const USAGE = `cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer)

Usage: cdp <command> [args]

  list                              List open pages (shows unique target prefixes)
  snap  <target>                    Accessibility tree snapshot
  eval  <target> <expr>             Evaluate JS expression
  shot  <target> [file]             Screenshot (default: state-dir/screenshot.png); prints coordinate mapping
  html  <target> [selector]         Get HTML (full page or CSS selector)
  nav   <target> <url>              Navigate to URL and wait for load completion
  net   <target>                    Network performance entries
  click   <target> <selector>       Click an element by CSS selector
  clickxy <target> <x> <y>          Click at CSS pixel coordinates (see coordinate note below)
  type    <target> <text>           Type text at current focus via Input.insertText
                                    Works in cross-origin iframes unlike eval-based approaches
  loadall <target> <selector> [ms]  Repeatedly click a "load more" button until it disappears
                                    Optional interval in ms between clicks (default 500)
  evalraw <target> <method> [json]  Send a raw CDP command; returns JSON result
                                    e.g. evalraw <t> "DOM.getDocument" '{}'
  batch <target> <json>             Run multiple page commands in one daemon roundtrip
                                    JSON: [{"cmd":"eval","args":["document.title"]}, ...]
  stop  [target]                    Stop daemon(s)

<target> is a unique targetId prefix from "cdp list". If a prefix is ambiguous,
use more characters.

COORDINATE SYSTEM
  shot captures the viewport at the device's native resolution.
  The screenshot image size = CSS pixels × DPR (device pixel ratio).
  For CDP Input events (clickxy, etc.) you need CSS pixels, not image pixels.

    CSS pixels = screenshot image pixels / DPR

  shot prints the DPR and an example conversion for the current page.
  Typical Retina (DPR=2): CSS px ≈ screenshot px × 0.5
  If your viewer rescales the image further, account for that scaling too.

EVAL SAFETY NOTE
  Avoid index-based DOM selection (querySelectorAll(...)[i]) across multiple
  eval calls when the list can change between calls (e.g. after clicking
  "Ignore" buttons on a feed — indices shift). Prefer stable selectors or
  collect all data in a single eval.

DAEMON IPC (for advanced use / scripting)
  Each tab runs a persistent daemon under $PI_CHROME_CDP_STATE_DIR,
  $XDG_RUNTIME_DIR/pi-chrome-cdp-<uid>, or the system temp fallback.
  Protocol: newline-delimited JSON (one JSON object per line, UTF-8).
    Request:  {"id":<number>, "cmd":"<command>", "args":["arg1","arg2",...]}
    Response: {"id":<number>, "ok":true,  "result":"<string>"}
           or {"id":<number>, "ok":false, "error":"<message>"}
  Commands mirror the CLI: snap, eval, shot, html, nav, net, click, clickxy,
  type, loadall, evalraw, batch, stop. Use evalraw to send arbitrary CDP methods.
  The socket disappears after 20 min of inactivity or when the tab closes.
`;

const NEEDS_TARGET = new Set([
	"snap",
	"snapshot",
	"eval",
	"shot",
	"screenshot",
	"html",
	"nav",
	"navigate",
	"net",
	"network",
	"click",
	"clickxy",
	"type",
	"loadall",
	"evalraw",
	"batch",
]);

async function main() {
	const [cmd, ...args] = process.argv.slice(2);

	// Daemon mode (internal)
	if (cmd === "_daemon") {
		await runDaemon(args[0]);
		return;
	}

	if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
		console.log(USAGE);
		process.exit(0);
	}

	// List — use existing daemon if available, otherwise direct
	if (cmd === "list" || cmd === "ls") {
		let pages;
		const existingSock = findAnyDaemonSocket();
		if (existingSock) {
			try {
				const conn = await connectToSocket(existingSock);
				const resp = await sendCommand(conn, { cmd: "list_raw" });
				if (resp.ok) pages = JSON.parse(resp.result);
			} catch {}
		}
		if (!pages) {
			// No daemon running — connect directly (will trigger one Allow)
			const cdp = new CDP();
			await cdp.connect(await getWsUrl());
			pages = await getPages(cdp);
			cdp.close();
		}
		writeFileSync(PAGES_CACHE, JSON.stringify(pages));
		console.log(formatPageList(pages));
		setImmediate(() => process.exit(0));
		return;
	}

	// Stop
	if (cmd === "stop") {
		await stopDaemons(args[0]);
		return;
	}

	// Page commands — need target prefix
	if (!NEEDS_TARGET.has(cmd)) {
		console.error(`Unknown command: ${cmd}\n`);
		console.log(USAGE);
		process.exit(1);
	}

	const targetPrefix = args[0];
	if (!targetPrefix) {
		console.error('Error: target ID required. Run "cdp list" first.');
		process.exit(1);
	}

	// Resolve prefix → full targetId from cache or running daemon
	let targetId;
	const daemonTargetIds = listDaemonSockets().map((d) => d.targetId);
	const daemonMatches = daemonTargetIds.filter((id) =>
		id.toUpperCase().startsWith(targetPrefix.toUpperCase()),
	);

	if (daemonMatches.length > 0) {
		targetId = resolvePrefix(targetPrefix, daemonTargetIds, "daemon");
	} else {
		if (!existsSync(PAGES_CACHE)) {
			console.error('No page list cached. Run "cdp list" first.');
			process.exit(1);
		}
		const pages = JSON.parse(readFileSync(PAGES_CACHE, "utf8"));
		targetId = resolvePrefix(
			targetPrefix,
			pages.map((p) => p.targetId),
			"target",
			'Run "cdp list".',
		);
	}

	const cmdArgs = args.slice(1);

	if (cmd === "eval") {
		const expr = cmdArgs.join(" ");
		if (!expr) {
			console.error("Error: expression required");
			process.exit(1);
		}
		cmdArgs[0] = expr;
	} else if (cmd === "type") {
		// Join all remaining args as text (allows spaces)
		const text = cmdArgs.join(" ");
		if (!text) {
			console.error("Error: text required");
			process.exit(1);
		}
		cmdArgs[0] = text;
	} else if (cmd === "evalraw") {
		// args: [method, ...jsonParts] — join json parts in case of spaces
		if (!cmdArgs[0]) {
			console.error("Error: CDP method required");
			process.exit(1);
		}
		if (cmdArgs.length > 2) cmdArgs[1] = cmdArgs.slice(1).join(" ");
	} else if (cmd === "batch") {
		const json = cmdArgs.join(" ");
		if (!json) {
			console.error("Error: batch JSON required");
			process.exit(1);
		}
		cmdArgs.length = 1;
		cmdArgs[0] = json;
	}

	if ((cmd === "nav" || cmd === "navigate") && !cmdArgs[0]) {
		console.error("Error: URL required");
		process.exit(1);
	}

	const conn = await getOrStartTabDaemon(targetId);
	const response = await sendCommand(conn, { cmd, args: cmdArgs });

	if (response.ok) {
		if (response.result) console.log(response.result);
	} else {
		console.error("Error:", response.error);
		process.exitCode = 1;
	}
}

export const __test = {
	envInt,
	evalStr,
	getDisplayPrefixLength,
	limitLinesMiddle,
	limitTextMiddle,
	navStr,
	netStr,
	resolvePrefix,
	sendCommand,
	shotStr,
	snapshotStr,
	waitForDocumentReady,
};

if (process.argv[1] === fileURLToPath(import.meta.url)) {
	main().catch((e) => {
		console.error(e.message);
		process.exit(1);
	});
}