pursr 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,8 @@ Most teams need **five separate tools** to do visual QA: a screenshot CLI, a reg
34
34
 
35
35
  - **A unified CLI** (`pursr`) for every capture, diff, sweep, and audit.
36
36
  - **An agent-grade MCP stdio server** (`pursr-mcp`) built on the official Model Context Protocol SDK, with persistent tabs, direct image responses, rendered-state inspection, actions, diagnostics, screenshots, sweeps, and resources.
37
- - **A library API** with 23 subpath modules, so you can embed the browser and QA primitives in your own tooling.
37
+ - **Visual Operator** sessions with a rendered cursor, target labels, click markers, visible Chrome windows, and authenticated Chrome attachment over CDP.
38
+ - **A library API** with 24 subpath modules, so you can embed the browser and QA primitives in your own tooling.
38
39
  - **A plugin system** for custom viewports, sweep ops, and capture hooks.
39
40
  - **PDF reports + AI diff summaries** built in - render a sweep to a styled PDF or ask a vision LLM to describe the regression in plain language.
40
41
  - **Zero browser bundled** - drives your system Chrome via Playwright. No 200 MB Chromium download.
@@ -79,7 +80,8 @@ pursr sweep ./plan.json # see plans/ for an example
79
80
  | Multi-viewport capture | 10+ presets (mobile, tablet, desktop, ultrawide) | `--preset mobile-375` |
80
81
  | Layered states | entity / terrain / hud / ui isolation | `--layer entity` |
81
82
  | Animation freeze | pause CSS/JS animations for stable frames | `--no-animation` |
82
- | Cursor overlay | pointer / grab / grabbing / crosshair | `--cursor crosshair` |
83
+ | Cursor overlay | pointer / grab / grabbing / crosshair | `--cursor crosshair` |
84
+ | Visual Operator | rendered cursor, target labels, click markers, headed and CDP sessions | MCP session tools |
83
85
  | Grid overlay | spacing guides, custom color + tile size | `--grid --grid-tile 64` |
84
86
  | Camera control | zoom + pan via mouse wheel/drag | `--zoom 1.5 --panX 200` |
85
87
  | Frame timeline | N captures at intervalMs for animations | `pursr frames <url> 8 200` |
@@ -188,10 +190,10 @@ npx pursr-mcp --verbose
188
190
 
189
191
  | Tool | Description |
190
192
  | --- | --- |
191
- | `pursr_session_open` | Open a persistent browser tab for iterative agent work |
193
+ | `pursr_session_open` | Open a headless, visible, or CDP browser session with optional Visual Operator |
192
194
  | `pursr_sessions` | List active browser sessions |
193
195
  | `pursr_snapshot` | Visible rendered nodes, geometry, semantics, and computed styles |
194
- | `pursr_act` | Click, hover, fill, type, scroll, navigate, reload, and more |
196
+ | `pursr_act` | Interact plus move cursor, annotate targets, and clear visual feedback |
195
197
  | `pursr_screenshot` | Return the current PNG directly to the vision model |
196
198
  | `pursr_inspect` | Inspect exact geometry, computed styles, and stacking ancestors |
197
199
  | `pursr_diagnostics` | Read console, page errors, failed requests, and HTTP failures |
@@ -229,6 +231,52 @@ Example action arguments:
229
231
  ]
230
232
  }
231
233
  ```
234
+
235
+ ### Visual Operator
236
+
237
+ Set `visual: true` to render the agent cursor and interaction feedback into screenshots. `mode: "visible"` enables it automatically and opens a Chrome window that a developer can watch.
238
+
239
+ ```json
240
+ {
241
+ "url": "http://localhost:3000",
242
+ "sessionId": "visual-review",
243
+ "mode": "visible",
244
+ "operatorColor": "#ff2ea6",
245
+ "slowMo": 80
246
+ }
247
+ ```
248
+
249
+ Visual actions use the regular `pursr_act` tool:
250
+
251
+ ```json
252
+ {
253
+ "sessionId": "visual-review",
254
+ "actions": [
255
+ { "type": "move", "x": 640, "y": 360, "durationMs": 300 },
256
+ { "type": "annotate", "selector": "role=button|Publish", "label": "Primary CTA" },
257
+ { "type": "click", "selector": "role=button|Publish" },
258
+ { "type": "clearAnnotations", "keepCursor": true }
259
+ ]
260
+ }
261
+ ```
262
+
263
+ To use an existing authenticated Chrome profile, start Chrome with a dedicated remote-debugging profile and attach using CDP. Do not expose the debugging port beyond localhost.
264
+
265
+ ```bash
266
+ chrome --remote-debugging-port=9222 --user-data-dir=/tmp/pursr-chrome
267
+ ```
268
+
269
+ ```json
270
+ {
271
+ "url": "https://app.example.com",
272
+ "sessionId": "signed-in-review",
273
+ "mode": "cdp",
274
+ "cdpUrl": "http://127.0.0.1:9222",
275
+ "visual": true
276
+ }
277
+ ```
278
+
279
+ Pursr opens a new tab in Chrome's default context, preserving that profile's cookies and login state. Closing the Pursr session disconnects without terminating the owner browser.
232
280
 
233
281
  ### Exposed Resources
234
282
 
@@ -364,7 +412,8 @@ import {
364
412
  saveBaseline, diffKey,
365
413
  startHarCapture, stopHarCapture, writeHar,
366
414
  loadAuthState,
367
- PursrMCPServer, loadMcpConfig,
415
+ PursrMCPServer, loadMcpConfig, BrowserSessionManager,
416
+ installVisualOperator, moveVisualCursor, highlightVisualTarget,
368
417
  validateSweepPlan,
369
418
  listResources, readResource,
370
419
  listViewports, resolveViewport, VIEWPORTS,
@@ -389,7 +438,9 @@ import { validateSweepPlan } from "pursr/sweep-schema";
389
438
  import { startHarCapture, stopHarCapture } from "pursr/har";
390
439
  import { saveAuthState, loadAuthState } from "pursr/auth";
391
440
  import { listResources, readResource } from "pursr/mcp-resources";
392
- import { PursrMCPServer } from "pursr/mcp";
441
+ import { PursrMCPServer } from "pursr/mcp";
442
+ import { BrowserSessionManager } from "pursr/session";
443
+ import { moveVisualCursor, highlightVisualTarget } from "pursr/visual-operator";
393
444
  ```
394
445
 
395
446
  ## Plugins
@@ -417,7 +468,9 @@ Plugins are auto-loaded from `plugins/` (built-in) or via `--plugin <path>`.
417
468
  ```
418
469
  src/
419
470
  index.js - public library entry
420
- mcp.js - MCP stdio server (JSON-RPC 2.0)
471
+ mcp.js - official MCP SDK stdio server
472
+ session.js - persistent headless, visible, and CDP sessions
473
+ visual-operator.js - rendered cursor and interaction feedback
421
474
  shoot.js - runShoot (overlays + camera + frame-stable)
422
475
  sweep.js - runSweep (validated, parallel pool)
423
476
  diff.js - pixelmatch wrapper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pursr",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "pursr — Visual QA, audit, and MCP for the browser. One CLI + one MCP server for screenshots, sweeps, baselines, diffs, axe-core a11y audits, HAR capture, and auth state — with parallel sweep workers, auto-healing selectors, and a plugin system. Zero browser bundled: drives your system Chrome via Playwright.",
6
6
  "homepage": "https://github.com/0xheycat/pursr",
@@ -39,7 +39,8 @@
39
39
  "./snap": "./src/snap.js",
40
40
  "./report": "./src/report.js",
41
41
  "./ai-diff": "./src/ai-diff.js",
42
- "./session": "./src/session.js"
42
+ "./session": "./src/session.js",
43
+ "./visual-operator": "./src/visual-operator.js"
43
44
  },
44
45
  "files": [
45
46
  "bin",
package/src/index.js CHANGED
@@ -25,7 +25,7 @@ import { runClick, runType, runWait, runSeq } from "./interact.js";
25
25
  import { listViewports, resolveViewport, VIEWPORTS } from "./viewport.js";
26
26
  import { applyCamera, waitForStableFrame } from "./overlays.js";
27
27
  import { loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp } from "./plugin.js";
28
- import { launch, newPage } from "./runway.js";
28
+ import { connectOverCDP, launch, newPage } from "./runway.js";
29
29
  import { parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut } from "./util.js";
30
30
  import { resolveLocator, parseTextSelector } from "./selector.js";
31
31
  import { captureDomSnapshot, captureDomSnapshotSidecar } from "./dom-snapshot.js";
@@ -45,6 +45,10 @@ import { runCheck } from "./check.js";
45
45
  import { renderSweepPdf } from "./report.js";
46
46
  import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
47
47
  import { BrowserSessionManager } from "./session.js";
48
+ import {
49
+ installVisualOperator, moveVisualCursor, highlightVisualTarget,
50
+ markVisualClick, clearVisualAnnotations, visualPointForLocator,
51
+ } from "./visual-operator.js";
48
52
 
49
53
 
50
54
  // Derive VERSION from package.json to prevent drift
@@ -64,7 +68,7 @@ export {
64
68
  // plugin system
65
69
  loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
66
70
  // low-level helpers (for plugin authors)
67
- launch, newPage,
71
+ launch, connectOverCDP, newPage,
68
72
  parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
69
73
  resolveLocator, parseTextSelector,
70
74
  // v3: selector healing, CI output, MCP server
@@ -88,6 +92,8 @@ export {
88
92
  renderSweepPdf,
89
93
  aiDiffSummary, aiDiffSidecar,
90
94
  BrowserSessionManager,
95
+ installVisualOperator, moveVisualCursor, highlightVisualTarget,
96
+ markVisualClick, clearVisualAnnotations, visualPointForLocator,
91
97
  VERSION,
92
98
  };
93
99
 
@@ -98,7 +104,7 @@ export default {
98
104
  listViewports, resolveViewport, VIEWPORTS,
99
105
  applyCamera, waitForStableFrame,
100
106
  loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
101
- launch, newPage,
107
+ launch, connectOverCDP, newPage,
102
108
  parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
103
109
  resolveLocator, parseTextSelector,
104
110
  resolveHealedSelector, healStepAction,
@@ -110,5 +116,7 @@ export default {
110
116
  // v6: PDF report, AI diff summary
111
117
  runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
112
118
  BrowserSessionManager,
119
+ installVisualOperator, moveVisualCursor, highlightVisualTarget,
120
+ markVisualClick, clearVisualAnnotations, visualPointForLocator,
113
121
  VERSION,
114
122
  };
package/src/mcp.js CHANGED
@@ -147,7 +147,7 @@ class PursrMCPServer {
147
147
  return [
148
148
  {
149
149
  name: "pursr_session_open",
150
- description: "Open a persistent browser tab for iterative agent work. State, hover, scroll, dialogs, and navigation persist until closed.",
150
+ description: "Open a persistent browser tab in headless, visible, or CDP mode. Visual sessions render cursor movement and interaction feedback into screenshots.",
151
151
  inputSchema: {
152
152
  type: "object",
153
153
  properties: {
@@ -155,6 +155,13 @@ class PursrMCPServer {
155
155
  sessionId: { type: "string", description: "Stable session name; generated when omitted" },
156
156
  preset: { type: "string", description: "Viewport preset" },
157
157
  width: { type: "number" }, height: { type: "number" }, dpr: { type: "number" },
158
+ mode: { type: "string", enum: ["headless", "visible", "cdp"], description: "Browser mode (default headless)" },
159
+ visible: { type: "boolean", description: "Alias for mode=visible" },
160
+ visual: { type: "boolean", description: "Enable rendered cursor and interaction overlays" },
161
+ cdpUrl: { type: "string", description: "Chrome DevTools endpoint for mode=cdp, e.g. http://127.0.0.1:9222" },
162
+ slowMo: { type: "number", description: "Delay Playwright operations in milliseconds" },
163
+ operatorColor: { type: "string", description: "Visual Operator accent color" },
164
+ timeoutMs: { type: "number", description: "Navigation/CDP connection timeout" },
158
165
  storageState: { description: "Playwright storageState object or file path" },
159
166
  },
160
167
  required: ["url"],
@@ -180,7 +187,7 @@ class PursrMCPServer {
180
187
  },
181
188
  {
182
189
  name: "pursr_act",
183
- description: "Perform ordered actions in a persistent session. Supported types: click, hover, fill, type, check, select, press, scroll, wait, sleep, navigate, reload, eval.",
190
+ description: "Perform ordered actions in a persistent session. Supports click, hover, fill, type, check, select, press, scroll, wait, sleep, navigate, reload, eval, move, annotate, and clearAnnotations.",
184
191
  inputSchema: {
185
192
  type: "object",
186
193
  properties: {
@@ -405,7 +412,11 @@ class PursrMCPServer {
405
412
 
406
413
  async _sessionOpen(args) {
407
414
  if (!args.url) throw new McpError(-32602, "Missing required: url");
408
- const flags = { preset: args.preset, width: args.width, height: args.height, dpr: args.dpr };
415
+ const flags = {
416
+ preset: args.preset, width: args.width, height: args.height, dpr: args.dpr,
417
+ mode: args.mode, visible: args.visible, visual: args.visual, cdpUrl: args.cdpUrl,
418
+ slowMo: args.slowMo, operatorColor: args.operatorColor, timeoutMs: args.timeoutMs,
419
+ };
409
420
  const result = await this.sessions.open({ sessionId: args.sessionId, url: args.url, flags, storageState: args.storageState });
410
421
  return this._text(result);
411
422
  }
package/src/runway.js CHANGED
@@ -43,24 +43,36 @@ function findChrome() {
43
43
 
44
44
  const BROWSER_ARGS = Object.freeze(["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]);
45
45
 
46
- export async function launch() {
47
- const chromium = await getChromium();
48
- const exec = findChrome();
49
- if (!exec) throw new Error("system Chrome not found in standard paths");
50
- return await chromium.launch({ headless: true, executablePath: exec, args: BROWSER_ARGS });
51
- }
52
-
53
- export async function newPage(browser, viewport, opts = {}) {
54
- const ctx = await browser.newContext({
55
- viewport: { width: viewport.width, height: viewport.height },
56
- deviceScaleFactor: viewport.dpr || 1,
57
- reducedMotion: "no-preference",
58
- colorScheme: "light",
59
- hasTouch: !!(viewport.name && viewport.name.startsWith("mobile")),
60
- isMobile: !!(viewport.name && viewport.name.startsWith("mobile")),
61
- storageState: opts.storageState || undefined,
62
- });
63
- const page = await ctx.newPage();
64
- page._pursrContext = ctx;
65
- return page;
66
- }
46
+ export async function launch(options = {}) {
47
+ const chromium = await getChromium();
48
+ const exec = findChrome();
49
+ if (!exec) throw new Error("system Chrome not found in standard paths");
50
+ return await chromium.launch({
51
+ headless: options.headless !== false,
52
+ executablePath: options.executablePath || exec,
53
+ slowMo: Math.max(0, Number(options.slowMo) || 0),
54
+ args: [...BROWSER_ARGS, ...(Array.isArray(options.args) ? options.args : [])],
55
+ });
56
+ }
57
+
58
+ export async function connectOverCDP(endpointURL, options = {}) {
59
+ if (!endpointURL || typeof endpointURL !== "string") throw new Error("cdpUrl is required for CDP mode");
60
+ const chromium = await getChromium();
61
+ return await chromium.connectOverCDP(endpointURL, { timeout: options.timeoutMs || 30_000 });
62
+ }
63
+
64
+ export async function newPage(browser, viewport, opts = {}) {
65
+ const ctx = opts.context || await browser.newContext({
66
+ viewport: { width: viewport.width, height: viewport.height },
67
+ deviceScaleFactor: viewport.dpr || 1,
68
+ reducedMotion: "no-preference",
69
+ colorScheme: "light",
70
+ hasTouch: !!(viewport.name && viewport.name.startsWith("mobile")),
71
+ isMobile: !!(viewport.name && viewport.name.startsWith("mobile")),
72
+ storageState: opts.storageState || undefined,
73
+ });
74
+ const page = await ctx.newPage();
75
+ if (opts.context) await page.setViewportSize({ width: viewport.width, height: viewport.height }).catch(() => {});
76
+ page._pursrContext = ctx;
77
+ return page;
78
+ }
package/src/session.js CHANGED
@@ -2,10 +2,18 @@
2
2
 
3
3
  import { mkdirSync, readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
- import { launch, newPage } from "./runway.js";
5
+ import { connectOverCDP, launch, newPage } from "./runway.js";
6
6
  import { resolveViewport } from "./viewport.js";
7
7
  import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
8
8
  import { resolveLocator } from "./selector.js";
9
+ import {
10
+ clearVisualAnnotations,
11
+ highlightVisualTarget,
12
+ installVisualOperator,
13
+ markVisualClick,
14
+ moveVisualCursor,
15
+ visualPointForLocator,
16
+ } from "./visual-operator.js";
9
17
 
10
18
  const MAX_DIAGNOSTICS = 250;
11
19
  const MAX_ACTIONS = 50;
@@ -37,8 +45,9 @@ function attachDiagnostics(page, diagnostics) {
37
45
  }
38
46
 
39
47
  export class BrowserSessionManager {
40
- constructor({ launchBrowser = launch, outputDir = process.cwd() } = {}) {
48
+ constructor({ launchBrowser = launch, connectBrowser = connectOverCDP, outputDir = process.cwd() } = {}) {
41
49
  this.launchBrowser = launchBrowser;
50
+ this.connectBrowser = connectBrowser;
42
51
  this.outputDir = outputDir;
43
52
  this.sessions = new Map();
44
53
  }
@@ -52,24 +61,34 @@ export class BrowserSessionManager {
52
61
  }
53
62
 
54
63
  list() {
55
- return [...this.sessions.values()].map(({ id, page, viewport, createdAt }) => ({ sessionId: id, url: page.url(), viewport, createdAt }));
64
+ return [...this.sessions.values()].map(({ id, page, viewport, mode, visual, createdAt }) => ({ sessionId: id, url: page.url(), viewport, mode, visual, createdAt }));
56
65
  }
57
66
 
58
67
  async open({ sessionId, url, flags = {}, storageState } = {}) {
59
68
  if (!url) throw new Error("url is required");
60
69
  const id = cleanId(sessionId);
61
70
  if (this.sessions.has(id)) await this.close(id);
62
- const browser = await this.launchBrowser();
71
+ const mode = flags.mode || (flags.cdpUrl ? "cdp" : flags.visible ? "visible" : "headless");
72
+ if (!new Set(["headless", "visible", "cdp"]).has(mode)) throw new Error("mode must be headless, visible, or cdp");
73
+ const visual = flags.visual === true || mode === "visible";
74
+ const operatorOptions = { color: flags.operatorColor || "#ff2ea6" };
75
+ const browser = mode === "cdp"
76
+ ? await this.connectBrowser(flags.cdpUrl, { timeoutMs: flags.timeoutMs })
77
+ : await this.launchBrowser({ headless: mode !== "visible", slowMo: flags.slowMo });
63
78
  try {
64
79
  const viewport = resolveViewport(flags);
65
- const page = await newPage(browser, viewport, { storageState });
80
+ const context = mode === "cdp" ? browser.contexts()[0] : null;
81
+ if (mode === "cdp" && !context) throw new Error("CDP browser has no default context");
82
+ const page = await newPage(browser, viewport, { storageState, context });
66
83
  const diagnostics = { console: [], errors: [], requests: [], responses: [] };
67
84
  attachDiagnostics(page, diagnostics);
85
+ if (visual) page.on("domcontentloaded", () => installVisualOperator(page, operatorOptions).catch(() => {}));
68
86
  const nav = await gotoOrThrow(page, url, { timeoutMs: flags.timeoutMs });
69
87
  await settle(page);
70
- const session = { id, browser, page, context: page._pursrContext, viewport, diagnostics, createdAt: new Date().toISOString() };
88
+ if (visual) await installVisualOperator(page, operatorOptions);
89
+ const session = { id, browser, page, context: page._pursrContext, viewport, mode, visual, operatorOptions, diagnostics, createdAt: new Date().toISOString() };
71
90
  this.sessions.set(id, session);
72
- return { sessionId: id, url: page.url(), title: await page.title(), viewport, status: nav.status, createdAt: session.createdAt };
91
+ return { sessionId: id, url: page.url(), title: await page.title(), viewport, mode, visual, status: nav.status, createdAt: session.createdAt };
73
92
  } catch (error) {
74
93
  try { await browser.close(); } catch {}
75
94
  throw error;
@@ -131,7 +150,8 @@ export class BrowserSessionManager {
131
150
  async act(sessionId, actions = []) {
132
151
  if (!Array.isArray(actions) || !actions.length) throw new Error("actions must be a non-empty array");
133
152
  if (actions.length > MAX_ACTIONS) throw new Error(`actions cannot exceed ${MAX_ACTIONS}`);
134
- const { page } = this.get(sessionId);
153
+ const session = this.get(sessionId);
154
+ const { page, visual, operatorOptions } = session;
135
155
  const trace = [];
136
156
  for (let i = 0; i < actions.length; i++) {
137
157
  const action = actions[i] || {};
@@ -141,19 +161,47 @@ export class BrowserSessionManager {
141
161
  if (["click", "hover", "fill", "type", "check", "select"].includes(op)) {
142
162
  const locator = await resolveLocator(page, action.selector);
143
163
  await locator.first().waitFor({ state: "visible", timeout: action.timeoutMs || CLICK_TIMEOUT_MS });
164
+ let point = null;
165
+ if (visual) {
166
+ point = await visualPointForLocator(locator.first());
167
+ await moveVisualCursor(page, point.x, point.y, { ...operatorOptions, durationMs: action.durationMs });
168
+ await highlightVisualTarget(page, point.rect, { ...operatorOptions, color: action.color, label: action.label || `${op}: ${action.selector}` });
169
+ step.cursor = { x: Math.round(point.x), y: Math.round(point.y) };
170
+ }
144
171
  if (op === "click") await locator.first().click();
145
172
  else if (op === "hover") await locator.first().hover();
146
173
  else if (op === "fill") await locator.first().fill(String(action.text ?? action.value ?? ""));
147
174
  else if (op === "type") await locator.first().pressSequentially(String(action.text ?? ""), { delay: action.delayMs || 10 });
148
175
  else if (op === "check") await locator.first().setChecked(action.checked !== false);
149
176
  else await locator.first().selectOption(action.value);
177
+ if (visual && op === "click" && point) await markVisualClick(page, point.x, point.y, { ...operatorOptions, color: action.color });
150
178
  step.selector = action.selector;
151
179
  } else if (op === "press") await page.keyboard.press(String(action.key));
152
180
  else if (op === "scroll") await page.mouse.wheel(Number(action.deltaX) || 0, Number(action.deltaY) || 0);
153
181
  else if (op === "wait") await (await resolveLocator(page, action.selector)).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || WAIT_DEFAULT_TIMEOUT_MS });
154
182
  else if (op === "sleep") await page.waitForTimeout(Math.max(0, Number(action.ms) || 0));
155
- else if (op === "navigate") await gotoOrThrow(page, action.url, { timeoutMs: action.timeoutMs });
156
- else if (op === "reload") await page.reload({ waitUntil: "domcontentloaded" });
183
+ else if (op === "navigate") {
184
+ await gotoOrThrow(page, action.url, { timeoutMs: action.timeoutMs });
185
+ if (visual) await installVisualOperator(page, operatorOptions);
186
+ } else if (op === "reload") {
187
+ await page.reload({ waitUntil: "domcontentloaded" });
188
+ if (visual) await installVisualOperator(page, operatorOptions);
189
+ } else if (op === "move") {
190
+ if (!visual) throw new Error("move requires a visual session");
191
+ step.cursor = await moveVisualCursor(page, action.x, action.y, { ...operatorOptions, durationMs: action.durationMs });
192
+ } else if (op === "annotate") {
193
+ if (!visual) throw new Error("annotate requires a visual session");
194
+ const locator = await resolveLocator(page, action.selector);
195
+ await locator.first().waitFor({ state: "visible", timeout: action.timeoutMs || CLICK_TIMEOUT_MS });
196
+ const point = await visualPointForLocator(locator.first());
197
+ await moveVisualCursor(page, point.x, point.y, { ...operatorOptions, durationMs: action.durationMs });
198
+ await highlightVisualTarget(page, point.rect, { ...operatorOptions, color: action.color, label: action.label || action.selector });
199
+ step.selector = action.selector;
200
+ step.cursor = { x: Math.round(point.x), y: Math.round(point.y) };
201
+ } else if (op === "clearAnnotations") {
202
+ if (!visual) throw new Error("clearAnnotations requires a visual session");
203
+ await clearVisualAnnotations(page, { keepCursor: action.keepCursor !== false });
204
+ }
157
205
  else if (op === "eval") step.result = await page.evaluate(String(action.js || ""));
158
206
  else throw new Error(`unknown action type: ${op}`);
159
207
  if (action.settleMs) await page.waitForTimeout(Number(action.settleMs));
@@ -0,0 +1,124 @@
1
+ // Visible cursor and interaction feedback for agent-driven browser sessions.
2
+
3
+ const DEFAULT_COLOR = "#ff2ea6";
4
+
5
+ function safeColor(value) {
6
+ const color = String(value || DEFAULT_COLOR).trim();
7
+ if (/^#[0-9a-f]{3,8}$/i.test(color)) return color;
8
+ if (/^(rgb|hsl)a?\([\d\s.,%+-]+\)$/i.test(color)) return color;
9
+ if (/^[a-z]{1,24}$/i.test(color)) return color;
10
+ return DEFAULT_COLOR;
11
+ }
12
+
13
+ export async function installVisualOperator(page, options = {}) {
14
+ const color = safeColor(options.color);
15
+ await page.evaluate(({ color }) => {
16
+ if (document.getElementById("__pursr_operator_style__")) return;
17
+ const style = document.createElement("style");
18
+ style.id = "__pursr_operator_style__";
19
+ style.textContent = `
20
+ #__pursr_cursor__ { position: fixed; left: 0; top: 0; width: 28px; height: 34px;
21
+ pointer-events: none; z-index: 2147483647; transform: translate(24px, 24px);
22
+ filter: drop-shadow(0 2px 2px rgba(0,0,0,.55)); transition: none; }
23
+ #__pursr_cursor__ svg { display: block; width: 100%; height: 100%; }
24
+ .__pursr_target__ { position: fixed; pointer-events: none; z-index: 2147483645;
25
+ border: 3px solid var(--pursr-color); border-radius: 7px;
26
+ box-shadow: 0 0 0 2px rgba(255,255,255,.92), 0 0 18px var(--pursr-color); }
27
+ .__pursr_label__ { position: absolute; left: -3px; bottom: calc(100% + 7px);
28
+ padding: 3px 7px; border-radius: 4px; background: var(--pursr-color); color: white;
29
+ font: 700 12px/1.3 ui-monospace, SFMono-Regular, Consolas, monospace;
30
+ white-space: nowrap; text-shadow: 0 1px 1px rgba(0,0,0,.35); }
31
+ .__pursr_click__ { position: fixed; width: 28px; height: 28px; margin: -14px 0 0 -14px;
32
+ pointer-events: none; z-index: 2147483646; border: 4px solid var(--pursr-color);
33
+ border-radius: 50%; box-shadow: 0 0 0 3px rgba(255,255,255,.9), 0 0 20px var(--pursr-color); }
34
+ `;
35
+ document.documentElement.appendChild(style);
36
+ const cursor = document.createElement("div");
37
+ cursor.id = "__pursr_cursor__";
38
+ cursor.dataset.x = "24";
39
+ cursor.dataset.y = "24";
40
+ cursor.style.setProperty("--pursr-color", color);
41
+ cursor.innerHTML = `<svg viewBox="0 0 28 34" aria-hidden="true"><path d="M3 2.5V27l6.8-6.2 4.7 10.2 5.2-2.5-4.7-9.8 9.4-.2z" fill="${color}" stroke="#fff" stroke-width="2.4" stroke-linejoin="round"/><path d="M3 2.5V27l6.8-6.2 4.7 10.2 5.2-2.5-4.7-9.8 9.4-.2z" fill="none" stroke="#16131a" stroke-width="1" stroke-linejoin="round"/></svg>`;
42
+ document.documentElement.appendChild(cursor);
43
+ }, { color });
44
+ }
45
+
46
+ export async function moveVisualCursor(page, x, y, options = {}) {
47
+ await installVisualOperator(page, options);
48
+ const durationMs = Math.max(0, Math.min(3000, Number(options.durationMs) || 220));
49
+ const point = { x: Math.round(Number(x) || 0), y: Math.round(Number(y) || 0) };
50
+ await page.evaluate(async ({ point, durationMs }) => {
51
+ const cursor = document.getElementById("__pursr_cursor__");
52
+ if (!cursor) return;
53
+ const startX = Number(cursor.dataset.x) || 0;
54
+ const startY = Number(cursor.dataset.y) || 0;
55
+ const started = performance.now();
56
+ await new Promise((resolve) => {
57
+ const frame = (now) => {
58
+ const progress = durationMs ? Math.min(1, (now - started) / durationMs) : 1;
59
+ const eased = 1 - Math.pow(1 - progress, 3);
60
+ const nextX = startX + (point.x - startX) * eased;
61
+ const nextY = startY + (point.y - startY) * eased;
62
+ cursor.style.transform = `translate(${nextX}px, ${nextY}px)`;
63
+ if (progress < 1) requestAnimationFrame(frame);
64
+ else resolve();
65
+ };
66
+ requestAnimationFrame(frame);
67
+ });
68
+ cursor.dataset.x = String(point.x);
69
+ cursor.dataset.y = String(point.y);
70
+ }, { point, durationMs });
71
+ await page.mouse.move(point.x, point.y, { steps: Math.max(1, Math.min(20, Math.ceil(durationMs / 20))) });
72
+ return point;
73
+ }
74
+
75
+ export async function highlightVisualTarget(page, rect, options = {}) {
76
+ await installVisualOperator(page, options);
77
+ const color = safeColor(options.color);
78
+ const label = String(options.label || "target").slice(0, 80);
79
+ await page.evaluate(({ rect, color, label }) => {
80
+ document.querySelectorAll(".__pursr_target__").forEach((node) => node.remove());
81
+ const target = document.createElement("div");
82
+ target.className = "__pursr_target__";
83
+ target.style.setProperty("--pursr-color", color);
84
+ target.style.left = `${Math.round(rect.x)}px`;
85
+ target.style.top = `${Math.round(rect.y)}px`;
86
+ target.style.width = `${Math.max(0, Math.round(rect.width))}px`;
87
+ target.style.height = `${Math.max(0, Math.round(rect.height))}px`;
88
+ const tag = document.createElement("span");
89
+ tag.className = "__pursr_label__";
90
+ tag.textContent = label;
91
+ target.appendChild(tag);
92
+ document.documentElement.appendChild(target);
93
+ }, { rect, color, label });
94
+ }
95
+
96
+ export async function markVisualClick(page, x, y, options = {}) {
97
+ await installVisualOperator(page, options);
98
+ const color = safeColor(options.color);
99
+ await page.evaluate(({ x, y, color }) => {
100
+ document.querySelectorAll(".__pursr_click__").forEach((node) => node.remove());
101
+ const marker = document.createElement("div");
102
+ marker.className = "__pursr_click__";
103
+ marker.style.setProperty("--pursr-color", color);
104
+ marker.style.left = `${Math.round(x)}px`;
105
+ marker.style.top = `${Math.round(y)}px`;
106
+ document.documentElement.appendChild(marker);
107
+ }, { x, y, color });
108
+ }
109
+
110
+ export async function clearVisualAnnotations(page, { keepCursor = true } = {}) {
111
+ await page.evaluate(({ keepCursor }) => {
112
+ document.querySelectorAll(".__pursr_target__, .__pursr_click__").forEach((node) => node.remove());
113
+ if (!keepCursor) {
114
+ document.getElementById("__pursr_cursor__")?.remove();
115
+ document.getElementById("__pursr_operator_style__")?.remove();
116
+ }
117
+ }, { keepCursor });
118
+ }
119
+
120
+ export async function visualPointForLocator(locator) {
121
+ const rect = await locator.boundingBox();
122
+ if (!rect) throw new Error("target has no visible bounding box");
123
+ return { rect, x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
124
+ }