// cdp — the smallest Chrome DevTools Protocol client that can act like a person. // // Why not Playwright: we are filming the REAL browser window with `reel` // (SCStream → hardware h264), not Playwright's internal recorder. We only need // CDP to move and click, and a direct socket keeps the browser a normal, // user-profile Chrome — signed in, with the session cookies the fuser app needs. // See vault/fuser/skills/drive-ui.md for launching that profiled CDP Chrome. // // Every click is a TRUSTED event (Input.dispatchMouseEvent), because React and // the fuser canvas both ignore synthetic ones. The visible pointer, though, is // ours (see cursor.mjs) — CDP moves no real cursor, and a tutorial where things // click themselves with no pointer in sight reads as a bug. // (WebSocket is a Node >= 22 global — no import, no `ws` dependency.) import { basename } from "node:path"; const HOST = process.env.CDP_HOST || "127.0.0.1"; const PORT = process.env.CDP_PORT || "9222"; const COMMAND_TIMEOUT_MS = Number(process.env.CAPTUTOR_CDP_COMMAND_TIMEOUT_MS || 15_000); const HEALTH_TIMEOUT_MS = Number(process.env.CAPTUTOR_CDP_HEALTH_TIMEOUT_MS || 4_000); const CRASH_EVENTS = new Set(["Inspector.targetCrashed", "Target.targetCrashed"]); export class BrowserCrashError extends Error { constructor(message, details = {}) { super(message); this.name = "BrowserCrashError"; this.code = "BROWSER_RENDERER_CRASH"; this.details = details; } } export async function attach(urlMatch) { const list = await (await fetch(`http://${HOST}:${PORT}/json`)).json(); const pages = list.filter((t) => t.type === "page"); const target = urlMatch ? pages.find((t) => (t.url || "").includes(urlMatch)) : pages[0]; if (!target) { throw new Error( `no CDP page${urlMatch ? ` matching "${urlMatch}"` : ""}. open pages:\n` + pages.map((p) => ` ${p.url}`).join("\n")); } const session = new Session(target.webSocketDebuggerUrl); try { await session.monitorCrashes(); return session; } catch (error) { await session.close(); throw error; } } // Short-lived inspection should always use this wrapper. A bare `attach()` in // a one-off node script leaves the WebSocket holding the process open, which // turns a two-second preflight into a tool timeout. Long-running screenplays // still own their Session directly and close it in Captutor's render teardown. export async function withSession(urlMatch, action) { const session = await attach(urlMatch); try { return await action(session); } finally { await session.close(); } } export class Session { constructor(wsUrl) { this.wsUrl = wsUrl; this.id = 0; this.pending = new Map(); this.crashEvent = null; this.closing = false; const entrypoint = process.argv[1] ? basename(process.argv[1]) : ""; // Claude frequently explores with `node -e` or a throwaway *probe*. Those // scripts used to print their answer and then live forever because nobody // closed CDP. Give only those explicitly ephemeral entrypoints a brief idle // reaper; production captutor.mjs sessions remain fully caller-owned. this.ephemeralIdleMs = ( process.env.CAPTUTOR_CDP_EPHEMERAL === "1" || !entrypoint || /(?:^|[-_.])probe/i.test(entrypoint) ) ? 5_000 : 0; this.idleTimer = null; this.ready = new Promise((res, rej) => { // node:ws is not built in; use the global WebSocket (node >= 22). this.ws = new globalThis.WebSocket(wsUrl); this.ws.addEventListener("open", () => res()); this.ws.addEventListener("error", (e) => rej(e)); this.ws.addEventListener("close", () => { if (!this.closing) this.markCrashed("WebSocket.closed", {}); else this.rejectPending(new Error("CDP session closed")); }); this.ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); if (CRASH_EVENTS.has(msg.method)) { this.markCrashed(msg.method, msg.params || {}); return; } const p = this.pending.get(msg.id); if (!p) return; this.pending.delete(msg.id); clearTimeout(p.timer); if (msg.error) { const error = new Error(JSON.stringify(msg.error)); if (/target.*crash|renderer.*crash/i.test(msg.error.message || "")) { this.markCrashed("CDP.error", { method:p.method, error:msg.error }); p.rej(this.crashError()); } else { p.rej(error); } } else { p.res(msg.result); } this.armIdleClose(); }); }); } rejectPending(error) { for (const { rej: reject, timer } of this.pending.values()) { clearTimeout(timer); reject(error); } this.pending.clear(); } markCrashed(method, params) { if (!this.crashEvent) { this.crashEvent = { method, params, at:new Date().toISOString() }; } this.rejectPending(this.crashError()); } crashError(context = "") { const where = context ? ` during ${context}` : ""; const signal = this.crashEvent?.method || "unresponsive target"; return new BrowserCrashError( `browser renderer unavailable${where} (${signal})`, { context, signal, event:this.crashEvent }, ); } armIdleClose() { clearTimeout(this.idleTimer); if (!this.ephemeralIdleMs || this.pending.size) return; this.idleTimer = setTimeout(() => { void this.close(); }, this.ephemeralIdleMs); } async send(method, params = {}, { timeoutMs = COMMAND_TIMEOUT_MS } = {}) { await this.ready; if (this.crashEvent) throw this.crashError(method); clearTimeout(this.idleTimer); const id = ++this.id; return new Promise((res, rej) => { const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)); this.armIdleClose(); }, timeoutMs) : null; this.pending.set(id, { res, rej, timer, method }); this.ws.send(JSON.stringify({ id, method, params })); }); } // Inspector.targetCrashed is the authoritative live signal. The bounded // Runtime heartbeat covers two less-obvious cases: attaching after the crash // already happened, and Chrome keeping a dead target in /json with its old // title and URL (which is exactly how an "Aw, Snap!" page fooled Captutor). async monitorCrashes() { try { await this.send("Inspector.enable", {}, { timeoutMs:HEALTH_TIMEOUT_MS }); await this.assertHealthy("attach"); } catch (error) { if (error instanceof BrowserCrashError) throw error; this.markCrashed("CDP.unresponsive", { phase:"attach", message:error.message, }); throw this.crashError("attach"); } } async assertHealthy(context = "browser", { timeoutMs = HEALTH_TIMEOUT_MS } = {}) { if (this.crashEvent) throw this.crashError(context); try { const result = await this.send("Runtime.evaluate", { expression:"({ readyState:document.readyState, href:location.href })", returnByValue:true, }, { timeoutMs }); if (!result?.result || result.exceptionDetails) { throw new Error("health expression did not return a page state"); } return result.result.value; } catch (error) { if (error instanceof BrowserCrashError) throw error; this.markCrashed("CDP.unresponsive", { context, message:error.message }); throw this.crashError(context); } } /// Evaluate ONE expression. Wrap statements in an IIFE — top-level `const` /// returns undefined and silently swallows what you meant to return. async eval(expression) { const r = await this.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, }); if (r.exceptionDetails) { throw new Error(`eval threw: ${r.exceptionDetails.exception?.description || ""}`); } return r.result?.value; } async nav(url) { await this.send("Page.enable"); await this.send("Page.navigate", { url }); await this.waitFor("document.readyState === 'complete'"); } /// Poll an expression until truthy. Every wait in a screenplay should be a /// real condition, never a sleep — a sleep that is too short produces a /// tutorial that films a spinner, and one that is too long films dead air. async waitFor(expression, { timeoutMs = 20000, everyMs = 100 } = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { if (await this.eval(`!!(${expression})`)) return true; } catch {} await new Promise((r) => setTimeout(r, everyMs)); } throw new Error(`waitFor timed out: ${expression}`); } /// Centre of an element, in viewport CSS pixels — what both our drawn cursor /// and the trusted click need. /// /// Accepts a CSS selector, or `text=Add a Node` to find a control by its /// visible label. Real UIs label things for people, not for us: fuser's node /// picker is just a