diff --git a/.agents/skills/chrome-cdp/SKILL.md b/.agents/skills/chrome-cdp/SKILL.md new file mode 100644 index 00000000..f147f5f1 --- /dev/null +++ b/.agents/skills/chrome-cdp/SKILL.md @@ -0,0 +1,75 @@ +--- +name: chrome-cdp +description: Interact with local Chrome browser session (only on explicit user approval after being asked to inspect, debug, or interact with a page open in Chrome) +--- + +# Chrome CDP + +Lightweight Chrome DevTools Protocol CLI. Connects directly via WebSocket — no Puppeteer, works with 100+ tabs, instant connection. + +## Prerequisites + +- Chrome with remote debugging enabled: open `chrome://inspect/#remote-debugging` and toggle the switch +- Node.js 22+ (uses built-in WebSocket) + +## Commands + +All commands use `scripts/cdp.mjs`. The `` is a **unique** targetId prefix from `list`; copy the full prefix shown in the `list` output (for example `6BE827FA`). The CLI rejects ambiguous prefixes. + +### List open pages + +```bash +scripts/cdp.mjs list +``` + +### Take a screenshot + +```bash +scripts/cdp.mjs shot [file] # default: /tmp/screenshot.png +``` + +Captures the **viewport only**. Scroll first with `eval` if you need content below the fold. Output includes the page's DPR and coordinate conversion hint (see **Coordinates** below). + +### Accessibility tree snapshot + +```bash +scripts/cdp.mjs snap +``` + +### Evaluate JavaScript + +```bash +scripts/cdp.mjs eval +``` + +> **Watch out:** avoid index-based selection (`querySelectorAll(...)[i]`) across multiple `eval` calls when the DOM can change between them (e.g. after clicking Ignore, card indices shift). Collect all data in one `eval` or use stable selectors. + +### Other commands + +```bash +scripts/cdp.mjs html [selector] # full page or element HTML +scripts/cdp.mjs nav # navigate and wait for load +scripts/cdp.mjs net # resource timing entries +scripts/cdp.mjs click # click element by CSS selector +scripts/cdp.mjs clickxy # click at CSS pixel coords +scripts/cdp.mjs type # Input.insertText at current focus; works in cross-origin iframes unlike eval +scripts/cdp.mjs loadall [ms] # click "load more" until gone (default 1500ms between clicks) +scripts/cdp.mjs evalraw [json] # raw CDP command passthrough +scripts/cdp.mjs stop [target] # stop daemon(s) +``` + +## Coordinates + +`shot` saves an image at native resolution: image pixels = CSS pixels × DPR. CDP Input events (`clickxy` etc.) take **CSS pixels**. + +``` +CSS px = screenshot image px / DPR +``` + +`shot` prints the DPR for the current page. Typical Retina (DPR=2): divide screenshot coords by 2. + +## Tips + +- Prefer `snap --compact` over `html` for page structure. +- Use `type` (not eval) to enter text in cross-origin iframes — `click`/`clickxy` to focus first, then `type`. +- Chrome shows an "Allow debugging" modal once per tab on first access. A background daemon keeps the session alive so subsequent commands need no further approval. Daemons auto-exit after 20 minutes of inactivity. diff --git a/.agents/skills/chrome-cdp/scripts/cdp.mjs b/.agents/skills/chrome-cdp/scripts/cdp.mjs new file mode 100755 index 00000000..73a8dd7d --- /dev/null +++ b/.agents/skills/chrome-cdp/scripts/cdp.mjs @@ -0,0 +1,838 @@ +#!/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 { readFileSync, writeFileSync, unlinkSync, existsSync, readdirSync } from 'fs'; +import { homedir } from 'os'; +import { resolve } from 'path'; +import { spawn } from 'child_process'; +import net from 'net'; + +const TIMEOUT = 15000; +const NAVIGATION_TIMEOUT = 30000; +const IDLE_TIMEOUT = 20 * 60 * 1000; +const DAEMON_CONNECT_RETRIES = 20; +const DAEMON_CONNECT_DELAY = 300; +const MIN_TARGET_PREFIX_LEN = 8; +const SOCK_PREFIX = '/tmp/cdp-'; +const PAGES_CACHE = '/tmp/cdp-pages.json'; + +function sockPath(targetId) { return `${SOCK_PREFIX}${targetId}.sock`; } + +function getWsUrl() { + const candidates = [ + resolve(homedir(), 'Library/Application Support/Google/Chrome/DevToolsActivePort'), + resolve(homedir(), '.config/google-chrome/DevToolsActivePort'), + ]; + const portFile = candidates.find(path => existsSync(path)); + if (!portFile) throw new Error(`Could not find DevToolsActivePort file in: ${candidates.join(', ')}`); + const lines = readFileSync(portFile, 'utf8').trim().split('\n'); + return `ws://127.0.0.1:${lines[0]}${lines[1]}`; +} + +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + +function listDaemonSockets() { + return readdirSync('/tmp') + .filter(f => f.startsWith('cdp-') && f.endsWith('.sock')) + .map(f => ({ + targetId: f.slice(4, -5), + socketPath: `/tmp/${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; +} + +// --------------------------------------------------------------------------- +// 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 = () => 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) => { + this.#pending.set(id, { resolve, reject }); + const msg = { id, method, params }; + if (sessionId) msg.sessionId = sessionId; + this.#ws.send(JSON.stringify(msg)); + setTimeout(() => { + if (this.#pending.has(id)) { + this.#pending.delete(id); + reject(new Error(`Timeout: ${method}`)); + } + }, TIMEOUT); + }); + } + + 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 lines.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; + 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 metrics = await cdp.send('Page.getLayoutMetrics', {}, sid); + dpr = metrics.visualViewport?.clientWidth + ? metrics.cssVisualViewport?.clientWidth + ? Math.round((metrics.visualViewport.clientWidth / metrics.cssVisualViewport.clientWidth) * 100) / 100 + : 1 + : 1; + // Simpler: deviceScaleFactor is on the root Page metrics + const { deviceScaleFactor } = await cdp.send('Emulation.getDeviceMetricsOverride', {}, sid).catch(() => ({})); + if (deviceScaleFactor) dpr = deviceScaleFactor; + } catch {} + // Fallback: try to get DPR from JS + if (dpr === 1) { + try { + const raw = await evalStr(cdp, sid, 'window.devicePixelRatio'); + const parsed = parseFloat(raw); + if (parsed > 0) dpr = parsed; + } catch {} + } + + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }, sid); + const out = filePath || '/tmp/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 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 evalStr(cdp, sid, expr); +} + +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 + })))`); + return JSON.parse(raw).map(e => + `${String(e.duration).padStart(5)}ms ${String(e.size || '?').padStart(8)}B ${e.type.padEnd(8)} ${e.name}` + ).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 (isNaN(cx) || isNaN(cy)) throw new Error('x and y must be 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); + await sleep(50); + 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 = 1500) { + if (!selector) throw new Error('CSS selector required'); + let clicks = 0; + const deadline = Date.now() + 5 * 60 * 1000; // 5-minute hard cap + while (Date.now() < deadline) { + const exists = await evalStr(cdp, sid, + `!!document.querySelector(${JSON.stringify(selector)})` + ); + if (exists !== 'true') break; + const clickExpr = ` + (function() { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) return false; + el.scrollIntoView({ block: 'center' }); + el.click(); + return true; + })() + `; + const clicked = await evalStr(cdp, sid, clickExpr); + if (clicked !== 'true') break; + clicks++; + 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(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 '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': result = await loadAllStr(cdp, sessionId, args[0], args[1] ? parseInt(args[1]) : 1500); 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": , "cmd": "", "args": ["arg1", "arg2", ...] } + // Response: { "id": , "ok": , "result": "" } + // or { "id": , "ok": false, "error": "" } + 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); +} + +// --------------------------------------------------------------------------- +// 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++) { + 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 [args] + + list List open pages (shows unique target prefixes) + snap Accessibility tree snapshot + eval Evaluate JS expression + shot [file] Screenshot (default: /tmp/screenshot.png); prints coordinate mapping + html [selector] Get HTML (full page or CSS selector) + nav Navigate to URL and wait for load completion + net Network performance entries + click Click an element by CSS selector + clickxy Click at CSS pixel coordinates (see coordinate note below) + type Type text at current focus via Input.insertText + Works in cross-origin iframes unlike eval-based approaches + loadall [ms] Repeatedly click a "load more" button until it disappears + Optional interval in ms between clicks (default 1500) + evalraw [json] Send a raw CDP command; returns JSON result + e.g. evalraw "DOM.getDocument" '{}' + stop [target] Stop daemon(s) + + 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 at Unix socket: /tmp/cdp-.sock + Protocol: newline-delimited JSON (one JSON object per line, UTF-8). + Request: {"id":, "cmd":"", "args":["arg1","arg2",...]} + Response: {"id":, "ok":true, "result":""} + or {"id":, "ok":false, "error":""} + Commands mirror the CLI: snap, eval, shot, html, nav, net, click, clickxy, + type, loadall, evalraw, 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', +]); + +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(getWsUrl()); + pages = await getPages(cdp); + cdp.close(); + } + writeFileSync(PAGES_CACHE, JSON.stringify(pages)); + console.log(formatPageList(pages)); + setTimeout(() => process.exit(0), 100); + 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 conn = await getOrStartTabDaemon(targetId); + + 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(' '); + } + + if ((cmd === 'nav' || cmd === 'navigate') && !cmdArgs[0]) { + console.error('Error: URL required'); + process.exit(1); + } + + 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; + } +} + +main().catch(e => { console.error(e.message); process.exit(1); }); diff --git a/.agents/skills/cognitive-load/SKILL.md b/.agents/skills/cognitive-load/SKILL.md index 5094b9c9..8606749b 100644 --- a/.agents/skills/cognitive-load/SKILL.md +++ b/.agents/skills/cognitive-load/SKILL.md @@ -1,3 +1,8 @@ +--- +name: cognitive-load +description: Reduce cognitive load when implementing or refactoring code for readability. +--- + From [Cognitive Load prompt](https://github.com/zakirullin/cognitive-load/blob/main/README.prompt.md). You are an engineer who writes code for **human brains, not machines**. You favour code that is simple to undertand and maintain. Remember at all times that the code you will be processed by human brain. The brain has a very limited capacity. People can only hold ~4 chunks in their working memory at once. If there are more than four things to think about, it feels mentally taxing for us. diff --git a/.agents/skills/conventional-commits/SKILL.md b/.agents/skills/conventional-commits/SKILL.md index 7515d7c5..9df40393 100644 --- a/.agents/skills/conventional-commits/SKILL.md +++ b/.agents/skills/conventional-commits/SKILL.md @@ -1,6 +1,6 @@ --- name: conventional-commits -description: Write commit messages using Conventional Commits 1.0.0. Use when creating, reviewing, or fixing commit messages so they follow [optional scope]: with optional body/footer and proper BREAKING CHANGE handling. +description: Use Conventional Commits when creating reviewing or fixing commit messages. --- # Conventional Commits diff --git a/.agents/skills/patch-first-debugging/SKILL.md b/.agents/skills/patch-first-debugging/SKILL.md new file mode 100644 index 00000000..3b8129a7 --- /dev/null +++ b/.agents/skills/patch-first-debugging/SKILL.md @@ -0,0 +1,76 @@ +--- +name: patch-first-debugging +description: Debug and repair code using minimal, reviewable patches instead of full-file rewrites. Use this whenever the user is fixing a bug, iterating on failing tests, asks for a surgical code change, wants a minimal diff, or is in a tight debug loop where output size and token cost matter. Prefer this even if the user does not explicitly say "patch." +--- + +# Patch-First Debugging + +## Goal + +Fix the problem with the smallest correct change, keep outputs reviewable, reduce token usage in iterative debug loops, and avoid regenerating entire files unless a full rewrite is clearly justified. + +## Why this skill exists + +- Smaller patches usually cost fewer tokens than full-file rewrites. +- Minimal diffs are easier to review and less likely to introduce unrelated churn. +- Tight debug loops work better when each iteration changes only the code justified by the latest failure. + +## When to use + +- Bug fixes +- Failing test or regression loops +- Small refactors with no intended behavior change +- Requests for minimal diffs or surgical edits +- Cases where only one or two files appear relevant + +## Workflow + +1. Reproduce or identify the failure. +2. Narrow scope to the smallest relevant file, function, or branch. +3. Inspect existing code before proposing changes. +4. Make the minimal patch that addresses the root cause. +5. Run the smallest verification that proves the fix. +6. Expand scope only if the minimal patch fails or the evidence points elsewhere. + +## Output rules + +- Prefer patch-oriented edits over full-file rewrites. +- Prefer unified diffs, `apply_patch`, or small in-place edits. +- Modify existing code instead of regenerating it. +- Keep outputs small when the task is local; this reduces token usage and makes repeated repair loops cheaper. +- Include only the changed context needed to understand the patch. +- Do not rewrite unrelated code, comments, or formatting. +- Rewrite a whole file only when the structure is fundamentally changing or patching would be less clear than replacement. + +## Context rules + +- Ask for or inspect only the relevant file, function, error, and test output first. +- Avoid pulling in whole directories or broad repo context unless the first pass is insufficient. +- If the problem spans multiple files, add them one at a time based on evidence. + +## Verification rules + +- Prefer targeted tests, builds, or lint checks over full-suite runs. +- State what was verified. +- If verification was not possible, say so plainly. +- If the first patch does not hold, iterate with another minimal patch instead of broadening the rewrite immediately. + +## Escalation rules + +- If the bug report is vague, first identify the failure mode before editing. +- If the requested change would trigger broad churn, say that a larger refactor is needed and explain why patch-first is no longer the right shape. +- If a rewrite is necessary, say why the patch boundary broke down. + +## Examples + +**Example 1** +Input: "This test started failing after a refactor. Fix it with the smallest possible change." +Output: Reproduce the failure, patch the relevant file only, rerun the affected test file, and report the result. + +**Example 2** +Input: "Here is a stack trace and `auth.ts`. Find the bug and output only a unified diff." +Output: Limit analysis to `auth.ts` and the stack trace, then return a small diff instead of a full file. + +**Example 3** +Input: "Login regressed. Please avoid touching unrelated files." +Output: Inspect the failing path, make the smallest safe patch, and verify only the login-related tests or checks. diff --git a/.agents/skills/patch-first-debugging/evals/evals.json b/.agents/skills/patch-first-debugging/evals/evals.json new file mode 100644 index 00000000..1e46d90c --- /dev/null +++ b/.agents/skills/patch-first-debugging/evals/evals.json @@ -0,0 +1,38 @@ +{ + "skill_name": "patch-first-debugging", + "evals": [ + { + "id": 1, + "prompt": "A Vitest file started failing after a small refactor. Fix the bug with the smallest possible change, avoid touching unrelated files, and verify only the most relevant test target first.", + "expected_output": "The response narrows scope quickly, favors a minimal patch over a rewrite, and runs or recommends a targeted verification step.", + "files": [], + "expectations": [ + "The workflow focuses on the smallest relevant file or function first", + "The solution prefers a patch or surgical edit instead of rewriting whole files", + "The verification step is targeted rather than defaulting to the full test suite" + ] + }, + { + "id": 2, + "prompt": "Here is a stack trace and one file, auth.ts. Find the bug and output only a unified diff. Do not return the full file.", + "expected_output": "The response honors the patch-only format and limits analysis to the supplied file and error context.", + "files": [], + "expectations": [ + "The output format is a diff or patch rather than a full-file rewrite", + "The response stays scoped to auth.ts and the provided failure context", + "The edit changes only the code needed to address the bug" + ] + }, + { + "id": 3, + "prompt": "Login regressed, but the root cause is unclear. Investigate carefully, start with the smallest likely surface area, and only broaden scope if the first patch does not hold.", + "expected_output": "The response follows a patch-first debugging loop, explains when to widen scope, and avoids broad churn before evidence requires it.", + "files": [], + "expectations": [ + "The response identifies or reproduces the failure before making broad changes", + "The initial repair attempt is a minimal patch", + "The response explains that wider refactoring or additional files should be pulled in only if evidence requires it" + ] + } + ] +} diff --git a/.agents/skills/test-fix-loop/SKILL.md b/.agents/skills/test-fix-loop/SKILL.md new file mode 100644 index 00000000..0c468d52 --- /dev/null +++ b/.agents/skills/test-fix-loop/SKILL.md @@ -0,0 +1,173 @@ +--- +name: test-fix-loop +description: Build or use a tight automated test-fix loop that repairs one failing test at a time with minimal patches and narrow context. Use whenever the user wants a repair harness, constrained bug-fixing loop, failing-test repair workflow, or a prompt that turns the model into a surgical fix engine instead of a general coding assistant. +--- + +# Test-Fix Loop + +## Goal + +Drive a tight repair loop: + +1. run tests +2. find the failing test +3. collect traceback or stderr +4. collect only relevant files +5. ask the model for the smallest valid patch +6. apply the patch +7. rerun tests +8. repeat until pass or stop limit + +This skill is for narrow repair work, not broad refactoring or feature design. + +## Core stance + +Treat the model as a constrained repair engine. + +- Keep context small. +- Fix the reported failure only. +- Prefer surgical diffs over rewrites. +- Preserve existing style and structure. +- Stop when the failure is ambiguous. + +## When to use + +Use this skill when the user wants any of the following: + +- an automated test-fix harness +- a minimal-patch bug fixing loop +- a failing-test repair workflow +- a prompt that returns only a patch +- strict guardrails against unrelated refactors + +Do not use this skill for greenfield design, large refactors, or vague requests like "improve this codebase." + +## Required loop shape + +Keep the loop conceptually this small: + +```text +run tests +-> find failing test +-> collect traceback / stderr +-> collect only relevant files +-> ask model for minimal patch +-> apply patch +-> rerun tests +-> repeat until pass or stop limit +``` + +Do not expand this into a broader autonomous workflow unless the user explicitly asks for that. + +## Prompt contract + +When drafting the repair prompt, preserve these rules: + +- fix only the failing issue +- return a minimal diff +- avoid unrelated refactors +- preserve existing style +- stop if the failure is ambiguous + +Use a system-style prompt close to this: + +```text +You are a repair agent working in an automated test-fix loop. + +Goal: +Make the smallest valid code change that fixes the reported failure. + +Rules: +- Return only a unified diff patch. +- Do not rewrite entire files unless absolutely necessary. +- Do not refactor unrelated code. +- Preserve current code style and structure. +- Prefer surgical edits. +- If the error cannot be fixed confidently from the provided context, return: + CANNOT_FIX_CONFIDENTLY + +You will receive: +1. failing test name +2. error output +3. relevant file contents +4. optional project rules +``` + +If the user explicitly wants a short explanation with the patch, keep it brief and machine-friendly. Otherwise prefer patch-only output. + +## Iteration payload + +Each repair attempt should include only: + +- failing test name +- error output +- relevant files +- project rules + +Send only the smallest useful code context. Usually 2 to 5 files max. + +Good file sources: + +- file from traceback +- failed test file +- directly implicated source module +- config file only if clearly involved + +Bad pattern: + +- dumping large parts of the repo "just in case" + +## Patch shape + +Prefer unified diff output like this: + +```diff +--- a/auth/token.ts ++++ b/auth/token.ts +@@ +- if (token.isValid()) { ++ if (token.isValid() && !token.isExpired()) { + return true + } +``` + +Do not return essays, rewritten full files, or unrelated cleanup. + +## Guardrails + +Use hard stops to control cost and drift: + +- max repair attempts per failure +- stop after repeated no-op patches +- stop if the same error repeats unchanged +- stop if failures increase sharply +- stop if the patch touches unrelated files + +Before applying a patch automatically, validate: + +- patch parses correctly +- changed files are allowed +- forbidden files are untouched +- diff size is under threshold +- tests are still runnable + +Reject patches that fail these checks. + +## Review pass + +If the user wants a safer loop, recommend a two-step flow: + +1. repair model proposes patch +2. review model checks likely fix and collateral risk + +Keep the review narrow. It should judge the candidate patch, not redesign the solution. + +## Output expectations + +When helping the user build this workflow: + +- keep recommendations concrete +- preserve the tight loop shape +- avoid turning the pattern into a generic coding agent +- name any disagreement with the user's design explicitly before changing it + diff --git a/AGENTS.md b/AGENTS.md index 93edd3eb..18277cf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,8 @@ DataConnect is the protocol client: it runs connectors, orchestrates grants, and - Prefer retrieval‑led reasoning for project‑specific knowledge. - Don’t overwrite comments; don’t change styles/classes unless asked. -- When I report a bug, don't start by trying to fix it. Instead, start by writing a test that reproduces the bug. Then, have subagents try to fix the bug and prove it with a passing test. +- Prefer minimal patches over full-file rewrites when feasible; this keeps outputs smaller, reduces token usage in debug loops, and makes changes easier to review. Keep context scoped to the relevant file, function, and failure, and avoid regenerating entire files unless a full rewrite is clearly justified. +- When I report a bug, do not start with a fix. First reproduce the bug and add the smallest failing test for the reported behavior when feasible. Then have subagents propose fixes, and accept a fix only when that reproducing test passes. - Commit only when asked; never push; stage explicit paths only (no `git add .`, `-A`, `-u`, `git commit -a`); run relevant tests before commit. - For all commit actions, follow `.agents/skills/commit-discipline/SKILL.md` exactly. - For links/actions that open URLs or local file/folder paths, use shared helpers in `src/lib/open-resource.ts` and `src/lib/tauri-paths.ts`; avoid inline runtime/OS branching in page components. @@ -52,4 +53,6 @@ Use skills only when the task matches; explore the code first. - Text usage edits: when changing usage of `src/components/typography/text.tsx`, read ui-component-audit first (soft default; use judgment). - Testing: invoke react-testing when writing/running tests or before commit. - Linear: invoke linear skill when asked to create/update tickets or statuses. +- Commit messages: invoke conventional-commits when creating, reviewing, or fixing commit messages. +- Test-fix harnesses: invoke test-fix-loop when the user wants a constrained failing-test repair loop, minimal-patch harness, or automated test-fix workflow. - Committing: invoke committing skill only when user explicitly asks to commit. diff --git a/src/components/typography/text.tsx b/src/components/typography/text.tsx index a79fdea4..12dd6a9f 100644 --- a/src/components/typography/text.tsx +++ b/src/components/typography/text.tsx @@ -129,6 +129,10 @@ export const textVariants = cva( "list-disc", ], }, + textBox: { + // Perfectly trims the text box to the actual glyph bounds + true: "[text-box-trim:trim-both_cap_alphabetic]", + }, }, compoundVariants: [ // mono displays @@ -199,6 +203,7 @@ export const Text = ({ optical, truncate, bullet, + textBox, pre, link, dim, @@ -255,6 +260,7 @@ export const Text = ({ truncate, pre: preProp, bullet: bulletProp, + textBox, link: linkProp, withIcon, dim, diff --git a/src/styles/index.css b/src/styles/index.css index 4985cfbb..4177c76a 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -328,11 +328,4 @@ } } -/* Draggable title bar area for macOS */ -.titlebar-drag-region { - -webkit-app-region: drag; -} -.titlebar-no-drag { - -webkit-app-region: no-drag; -}