ornold-mcp 1.3.2 → 1.3.3

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.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Concurrency Management
3
+ * Утилиты для управления параллельным выполнением операций
4
+ */
5
+
6
+ import * as os from 'os';
7
+
8
+ /**
9
+ * Detect optimal concurrency based on system resources.
10
+ * Considers: free RAM, total RAM, CPU cores, and number of browsers.
11
+ */
12
+ export function detectOptimalConcurrency(browserCount: number = 0): {
13
+ default: number;
14
+ heavy: number;
15
+ light: number;
16
+ } {
17
+ const cpuCores = os.cpus().length;
18
+ const totalMemGB = os.totalmem() / (1024 ** 3);
19
+ const freeMemGB = os.freemem() / (1024 ** 3);
20
+
21
+ // Each browser operation needs ~200-500MB RAM headroom
22
+ // For heavy ops (screenshots) need more
23
+ const memPerHeavyOp = 0.5; // GB
24
+ const memPerLightOp = 0.2; // GB
25
+
26
+ // Calculate limits based on resources
27
+ // RAM is usually the bottleneck for browsers
28
+ const ramBasedHeavy = Math.floor(freeMemGB / memPerHeavyOp);
29
+ const ramBasedLight = Math.floor(freeMemGB / memPerLightOp);
30
+
31
+ // CPU-based limits (browsers are mostly I/O bound, so we can go higher)
32
+ const cpuBasedDefault = Math.max(2, Math.floor(cpuCores / 2));
33
+ const cpuBasedLight = Math.max(3, cpuCores);
34
+
35
+ // Final limits - take minimum of RAM and CPU based limits
36
+ // No hard limits for powerful servers
37
+ const heavyConcurrency = Math.max(2, Math.min(ramBasedHeavy, cpuBasedDefault));
38
+ const lightConcurrency = Math.max(3, Math.min(ramBasedLight, cpuBasedLight));
39
+ const defaultConcurrency = Math.max(2, Math.floor((heavyConcurrency + lightConcurrency) / 2));
40
+
41
+ // If we know browser count, don't exceed it
42
+ const effectiveHeavy = browserCount > 0 ? Math.min(heavyConcurrency, browserCount) : heavyConcurrency;
43
+ const effectiveLight = browserCount > 0 ? Math.min(lightConcurrency, browserCount) : lightConcurrency;
44
+ const effectiveDefault = browserCount > 0 ? Math.min(defaultConcurrency, browserCount) : defaultConcurrency;
45
+
46
+ console.error(`[Concurrency] System: ${cpuCores} cores, ${totalMemGB.toFixed(1)}GB total RAM, ${freeMemGB.toFixed(1)}GB free`);
47
+ console.error(`[Concurrency] Detected limits: default=${effectiveDefault}, heavy=${effectiveHeavy}, light=${effectiveLight}`);
48
+
49
+ return {
50
+ default: effectiveDefault,
51
+ heavy: effectiveHeavy,
52
+ light: effectiveLight,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Simple concurrency limiter to prevent overwhelming the system
58
+ * when operating on many browsers simultaneously.
59
+ * Note: In Node.js single-threaded event loop, synchronous operations
60
+ * before await are atomic, so this is safe.
61
+ */
62
+ export class ConcurrencyLimiter {
63
+ private running = 0;
64
+ private queue: Array<() => void> = [];
65
+
66
+ constructor(private maxConcurrent: number) {
67
+ if (maxConcurrent < 1) {
68
+ throw new Error('maxConcurrent must be at least 1');
69
+ }
70
+ }
71
+
72
+ get limit(): number {
73
+ return this.maxConcurrent;
74
+ }
75
+
76
+ get active(): number {
77
+ return this.running;
78
+ }
79
+
80
+ get pending(): number {
81
+ return this.queue.length;
82
+ }
83
+
84
+ async acquire(): Promise<void> {
85
+ // Synchronous check and increment - safe in single-threaded Node.js
86
+ if (this.running < this.maxConcurrent) {
87
+ this.running++;
88
+ return;
89
+ }
90
+
91
+ return new Promise((resolve) => {
92
+ this.queue.push(() => {
93
+ this.running++;
94
+ resolve();
95
+ });
96
+ });
97
+ }
98
+
99
+ release(): void {
100
+ if (this.running <= 0) {
101
+ console.error('[ConcurrencyLimiter] Warning: release() called when running <= 0');
102
+ return;
103
+ }
104
+
105
+ this.running--;
106
+ const next = this.queue.shift();
107
+ if (next) next();
108
+ }
109
+
110
+ async run<T>(fn: () => Promise<T>): Promise<T> {
111
+ await this.acquire();
112
+ try {
113
+ return await fn();
114
+ } finally {
115
+ this.release();
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Human-like interaction utilities for anti-detection
3
+ * Provides realistic mouse movement, typing, and visual cursor
4
+ */
5
+
6
+ import type { Page, CDPSession } from 'patchright';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+
10
+ // ==================== DEBUG LOGGER ====================
11
+
12
+ const LOG_FILE = path.join(process.cwd(), 'logs', 'human-like-debug.log');
13
+
14
+ function debugLog(category: string, message: string, data?: any): void {
15
+ try {
16
+ const dir = path.dirname(LOG_FILE);
17
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
18
+
19
+ const ts = new Date().toISOString();
20
+ let line = `[${ts}] [${category}] ${message}`;
21
+ if (data !== undefined) {
22
+ line += ` | ${JSON.stringify(data)}`;
23
+ }
24
+ fs.appendFileSync(LOG_FILE, line + '\n');
25
+ } catch {
26
+ // Logging should never break the main flow
27
+ }
28
+ }
29
+
30
+ // ==================== RANDOM UTILITIES ====================
31
+
32
+ /** Gaussian-distributed random number (Box-Muller transform) */
33
+ function gaussianRandom(mean: number, stddev: number): number {
34
+ const u1 = Math.random();
35
+ const u2 = Math.random();
36
+ const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
37
+ return mean + z * stddev;
38
+ }
39
+
40
+ /** Clamped random number */
41
+ function clampedRandom(min: number, max: number): number {
42
+ return Math.min(max, Math.max(min, min + Math.random() * (max - min)));
43
+ }
44
+
45
+ /** Clamped gaussian */
46
+ function clampedGaussian(mean: number, stddev: number, min: number, max: number): number {
47
+ return Math.min(max, Math.max(min, gaussianRandom(mean, stddev)));
48
+ }
49
+
50
+ /** Sleep for ms */
51
+ function sleep(ms: number): Promise<void> {
52
+ return new Promise(resolve => setTimeout(resolve, ms));
53
+ }
54
+
55
+ // ==================== PER-BROWSER PROFILE ====================
56
+
57
+ export interface HumanProfile {
58
+ /** Base typing delay multiplier (0.8 - 1.2) */
59
+ typingSpeed: number;
60
+ /** Mouse speed multiplier (0.85 - 1.15) */
61
+ mouseSpeed: number;
62
+ /** Pre-action delay multiplier (0.7 - 1.3) */
63
+ actionDelay: number;
64
+ }
65
+
66
+ /** Generate a deterministic-ish but unique profile per browser */
67
+ export function generateProfile(browserId: string): HumanProfile {
68
+ // Simple hash from browserId to get consistent but varied profiles
69
+ let hash = 0;
70
+ for (let i = 0; i < browserId.length; i++) {
71
+ hash = ((hash << 5) - hash + browserId.charCodeAt(i)) | 0;
72
+ }
73
+ const seed = Math.abs(hash);
74
+
75
+ return {
76
+ typingSpeed: 0.8 + ((seed % 100) / 100) * 0.4, // 0.8 - 1.2
77
+ mouseSpeed: 0.85 + (((seed >> 8) % 100) / 100) * 0.3, // 0.85 - 1.15
78
+ actionDelay: 0.7 + (((seed >> 16) % 100) / 100) * 0.6, // 0.7 - 1.3
79
+ };
80
+ }
81
+
82
+ // ==================== DELAYS ====================
83
+
84
+ /** Pre-action delay (100-400ms base, scaled by profile) */
85
+ export async function preActionDelay(profile?: HumanProfile): Promise<void> {
86
+ const base = clampedGaussian(250, 80, 100, 400);
87
+ const ms = profile ? base * profile.actionDelay : base;
88
+ await sleep(ms);
89
+ }
90
+
91
+ /** Typing delay for a single character (40-180ms, with rare pauses) */
92
+ export function typingDelay(profile?: HumanProfile): number {
93
+ // 5% chance of a "thinking" pause
94
+ if (Math.random() < 0.05) {
95
+ const pause = clampedGaussian(350, 100, 200, 600);
96
+ return profile ? pause * profile.typingSpeed : pause;
97
+ }
98
+ const base = clampedGaussian(80, 30, 40, 180);
99
+ return profile ? base * profile.typingSpeed : base;
100
+ }
101
+
102
+ /** Short delay between mouse actions */
103
+ export async function humanDelay(min: number, max: number): Promise<void> {
104
+ await sleep(clampedRandom(min, max));
105
+ }
106
+
107
+ // ==================== BEZIER MOUSE MOVEMENT ====================
108
+
109
+ interface Point {
110
+ x: number;
111
+ y: number;
112
+ }
113
+
114
+ /** Cubic Bezier interpolation */
115
+ function cubicBezier(t: number, p0: number, p1: number, p2: number, p3: number): number {
116
+ const t2 = t * t;
117
+ const t3 = t2 * t;
118
+ const mt = 1 - t;
119
+ const mt2 = mt * mt;
120
+ const mt3 = mt2 * mt;
121
+ return mt3 * p0 + 3 * mt2 * t * p1 + 3 * mt * t2 * p2 + t3 * p3;
122
+ }
123
+
124
+ /** Generate Bezier path between two points with random control points */
125
+ function generateBezierPath(from: Point, to: Point, steps: number = 20): Point[] {
126
+ const dx = to.x - from.x;
127
+ const dy = to.y - from.y;
128
+ const distance = Math.sqrt(dx * dx + dy * dy);
129
+
130
+ // Control points: offset perpendicular to the line, scaled by distance
131
+ const spreadX = distance * 0.15;
132
+ const spreadY = distance * 0.15;
133
+
134
+ const cp1: Point = {
135
+ x: from.x + dx * 0.25 + (Math.random() - 0.5) * spreadX,
136
+ y: from.y + dy * 0.25 + (Math.random() - 0.5) * spreadY,
137
+ };
138
+ const cp2: Point = {
139
+ x: from.x + dx * 0.75 + (Math.random() - 0.5) * spreadX,
140
+ y: from.y + dy * 0.75 + (Math.random() - 0.5) * spreadY,
141
+ };
142
+
143
+ // More steps for longer distances
144
+ const actualSteps = Math.max(10, Math.min(40, Math.round(steps * (distance / 500))));
145
+
146
+ const points: Point[] = [];
147
+ for (let i = 0; i <= actualSteps; i++) {
148
+ const t = i / actualSteps;
149
+ // Ease in/out: slow start and end, fast middle
150
+ const eased = t < 0.5
151
+ ? 2 * t * t
152
+ : 1 - Math.pow(-2 * t + 2, 2) / 2;
153
+
154
+ points.push({
155
+ x: Math.round(cubicBezier(eased, from.x, cp1.x, cp2.x, to.x)),
156
+ y: Math.round(cubicBezier(eased, from.y, cp1.y, cp2.y, to.y)),
157
+ });
158
+ }
159
+
160
+ return points;
161
+ }
162
+
163
+ /** Move mouse along a human-like Bezier path */
164
+ export async function humanMouseMove(
165
+ page: Page,
166
+ from: Point,
167
+ to: Point,
168
+ profile?: HumanProfile
169
+ ): Promise<void> {
170
+ debugLog('MOUSE_MOVE', `from=(${from.x},${from.y}) to=(${to.x},${to.y})`);
171
+ const path = generateBezierPath(from, to);
172
+ const speedMult = profile?.mouseSpeed ?? 1;
173
+ debugLog('MOUSE_MOVE', `bezier path generated: ${path.length} steps, speedMult=${speedMult.toFixed(2)}`);
174
+
175
+ // Create CDP session once for all cursor updates
176
+ let cdpSession: any = null;
177
+ try {
178
+ cdpSession = await page.context().newCDPSession(page);
179
+ } catch (err) {
180
+ debugLog('MOUSE_MOVE', `CDP session creation failed, cursor won't be visible in screencast`, String(err));
181
+ }
182
+
183
+ for (let i = 0; i < path.length; i++) {
184
+ const point = path[i];
185
+ const delay = clampedRandom(5, 15) * speedMult;
186
+ try {
187
+ await page.mouse.move(point.x, point.y);
188
+ } catch (err) {
189
+ debugLog('MOUSE_MOVE', `ERROR at step ${i}/${path.length}: page.mouse.move(${point.x},${point.y}) failed`, String(err));
190
+ throw err;
191
+ }
192
+
193
+ // Update cursor position for screencast on every step (smooth movement)
194
+ if (cdpSession) {
195
+ try {
196
+ await cdpSession.send('Runtime.evaluate', {
197
+ expression: `window.__cpSrv = {x:${Math.round(point.x)},y:${Math.round(point.y)}}`,
198
+ returnByValue: false, // no need to read back on every step
199
+ });
200
+ } catch (err) {
201
+ // Ignore errors during movement, don't break the flow
202
+ }
203
+ }
204
+
205
+ await sleep(delay);
206
+ }
207
+
208
+ // Detach CDP session
209
+ if (cdpSession) {
210
+ try {
211
+ await cdpSession.detach();
212
+ } catch (err) {
213
+ // Ignore detach errors
214
+ }
215
+ }
216
+
217
+ debugLog('MOUSE_MOVE', `movement complete at (${to.x},${to.y})`);
218
+ console.log(`[HumanLike] Mouse moved smoothly to (${to.x},${to.y}), ${path.length} steps`);
219
+
220
+ // Show final cursor position via CDP overlay
221
+ await showCursorOverlay(page, to.x, to.y);
222
+ }
223
+
224
+ // ==================== CLICK HELPERS ====================
225
+
226
+ /** Random offset from element center for clicks */
227
+ export function clickOffset(width: number, height: number): Point {
228
+ // Offset within ~30% of element dimensions, clamped to ±8px
229
+ const maxOffX = Math.min(8, width * 0.15);
230
+ const maxOffY = Math.min(5, height * 0.15);
231
+ return {
232
+ x: clampedGaussian(0, maxOffX * 0.5, -maxOffX, maxOffX),
233
+ y: clampedGaussian(0, maxOffY * 0.5, -maxOffY, maxOffY),
234
+ };
235
+ }
236
+
237
+ /** Human-like click: mouse move → down → delay → up */
238
+ export async function humanClick(
239
+ page: Page,
240
+ target: Point,
241
+ fromPos: Point,
242
+ profile?: HumanProfile
243
+ ): Promise<void> {
244
+ debugLog('CLICK', `target=(${target.x},${target.y}) from=(${fromPos.x},${fromPos.y})`);
245
+ // Move to target
246
+ await humanMouseMove(page, fromPos, target, profile);
247
+
248
+ // Down → delay → up (real humans hold button for 50-120ms)
249
+ debugLog('CLICK', `mouse.down()`);
250
+ await page.mouse.down();
251
+ const holdDelay = clampedRandom(50, 120);
252
+ debugLog('CLICK', `holding for ${holdDelay.toFixed(0)}ms`);
253
+ await sleep(holdDelay);
254
+ debugLog('CLICK', `mouse.up()`);
255
+ await page.mouse.up();
256
+ debugLog('CLICK', `click complete`);
257
+ }
258
+
259
+ /** Get bounding box center + random offset for a locator */
260
+ export async function getClickTarget(
261
+ page: Page,
262
+ locator: any // Locator type
263
+ ): Promise<{ target: Point; box: { width: number; height: number } }> {
264
+ debugLog('GET_TARGET', `getting bounding box for locator`);
265
+ const box = await locator.boundingBox();
266
+ if (!box) {
267
+ debugLog('GET_TARGET', `ERROR: no bounding box returned (element not visible)`);
268
+ throw new Error('Element is not visible — no bounding box');
269
+ }
270
+ debugLog('GET_TARGET', `boundingBox: x=${box.x} y=${box.y} w=${box.width} h=${box.height}`);
271
+
272
+ const offset = clickOffset(box.width, box.height);
273
+ const target = {
274
+ x: Math.round(box.x + box.width / 2 + offset.x),
275
+ y: Math.round(box.y + box.height / 2 + offset.y),
276
+ };
277
+ debugLog('GET_TARGET', `center=(${Math.round(box.x + box.width/2)},${Math.round(box.y + box.height/2)}) offset=(${offset.x.toFixed(1)},${offset.y.toFixed(1)}) final=(${target.x},${target.y})`);
278
+
279
+ return {
280
+ target,
281
+ box: { width: box.width, height: box.height },
282
+ };
283
+ }
284
+
285
+ // ==================== CDP KEYBOARD EVENTS ====================
286
+
287
+ // Key code mapping for common characters
288
+ const KEY_CODES: Record<string, { keyCode: number; code: string; key: string }> = {};
289
+
290
+ // Populate a-z
291
+ for (let i = 0; i < 26; i++) {
292
+ const lower = String.fromCharCode(97 + i);
293
+ const upper = String.fromCharCode(65 + i);
294
+ KEY_CODES[lower] = { keyCode: 65 + i, code: `Key${upper}`, key: lower };
295
+ KEY_CODES[upper] = { keyCode: 65 + i, code: `Key${upper}`, key: upper };
296
+ }
297
+
298
+ // 0-9
299
+ for (let i = 0; i < 10; i++) {
300
+ const ch = String(i);
301
+ KEY_CODES[ch] = { keyCode: 48 + i, code: `Digit${i}`, key: ch };
302
+ }
303
+
304
+ // Common symbols
305
+ const SYMBOLS: Record<string, { keyCode: number; code: string }> = {
306
+ ' ': { keyCode: 32, code: 'Space' },
307
+ '.': { keyCode: 190, code: 'Period' },
308
+ ',': { keyCode: 188, code: 'Comma' },
309
+ '/': { keyCode: 191, code: 'Slash' },
310
+ '\\': { keyCode: 220, code: 'Backslash' },
311
+ '-': { keyCode: 189, code: 'Minus' },
312
+ '=': { keyCode: 187, code: 'Equal' },
313
+ '[': { keyCode: 219, code: 'BracketLeft' },
314
+ ']': { keyCode: 221, code: 'BracketRight' },
315
+ ';': { keyCode: 186, code: 'Semicolon' },
316
+ "'": { keyCode: 222, code: 'Quote' },
317
+ '`': { keyCode: 192, code: 'Backquote' },
318
+ '@': { keyCode: 50, code: 'Digit2' },
319
+ '!': { keyCode: 49, code: 'Digit1' },
320
+ '#': { keyCode: 51, code: 'Digit3' },
321
+ '$': { keyCode: 52, code: 'Digit4' },
322
+ '%': { keyCode: 53, code: 'Digit5' },
323
+ '^': { keyCode: 54, code: 'Digit6' },
324
+ '&': { keyCode: 55, code: 'Digit7' },
325
+ '*': { keyCode: 56, code: 'Digit8' },
326
+ '(': { keyCode: 57, code: 'Digit9' },
327
+ ')': { keyCode: 48, code: 'Digit0' },
328
+ '_': { keyCode: 189, code: 'Minus' },
329
+ '+': { keyCode: 187, code: 'Equal' },
330
+ '{': { keyCode: 219, code: 'BracketLeft' },
331
+ '}': { keyCode: 221, code: 'BracketRight' },
332
+ ':': { keyCode: 186, code: 'Semicolon' },
333
+ '"': { keyCode: 222, code: 'Quote' },
334
+ '~': { keyCode: 192, code: 'Backquote' },
335
+ '<': { keyCode: 188, code: 'Comma' },
336
+ '>': { keyCode: 190, code: 'Period' },
337
+ '?': { keyCode: 191, code: 'Slash' },
338
+ '|': { keyCode: 220, code: 'Backslash' },
339
+ '\n': { keyCode: 13, code: 'Enter' },
340
+ '\t': { keyCode: 9, code: 'Tab' },
341
+ };
342
+ for (const [ch, info] of Object.entries(SYMBOLS)) {
343
+ KEY_CODES[ch] = { ...info, key: ch };
344
+ }
345
+
346
+ /** Type a single character via CDP Input.dispatchKeyEvent */
347
+ async function cdpTypeChar(cdp: CDPSession, char: string): Promise<void> {
348
+ const info = KEY_CODES[char];
349
+ const code = info?.code ?? '';
350
+ const keyCode = info?.keyCode ?? char.charCodeAt(0);
351
+
352
+ debugLog('TYPE_CHAR', `char="${char}" code=${code} keyCode=${keyCode} (has mapping: ${!!info})`);
353
+
354
+ // keyDown — no text field here, text insertion happens via the char event
355
+ try {
356
+ await cdp.send('Input.dispatchKeyEvent', {
357
+ type: 'keyDown',
358
+ key: char,
359
+ code,
360
+ windowsVirtualKeyCode: keyCode,
361
+ nativeVirtualKeyCode: keyCode,
362
+ });
363
+ debugLog('TYPE_CHAR', `keyDown sent OK`);
364
+ } catch (err) {
365
+ debugLog('TYPE_CHAR', `keyDown FAILED`, String(err));
366
+ throw err;
367
+ }
368
+
369
+ // char event — this is what actually inserts the character
370
+ try {
371
+ await cdp.send('Input.dispatchKeyEvent', {
372
+ type: 'char',
373
+ text: char,
374
+ unmodifiedText: char,
375
+ });
376
+ debugLog('TYPE_CHAR', `char event sent OK`);
377
+ } catch (err) {
378
+ debugLog('TYPE_CHAR', `char event FAILED`, String(err));
379
+ throw err;
380
+ }
381
+
382
+ // Small delay between down and up (real key press duration)
383
+ const pressDelay = clampedRandom(20, 60);
384
+ await sleep(pressDelay);
385
+
386
+ // keyUp
387
+ try {
388
+ await cdp.send('Input.dispatchKeyEvent', {
389
+ type: 'keyUp',
390
+ key: char,
391
+ code,
392
+ windowsVirtualKeyCode: keyCode,
393
+ nativeVirtualKeyCode: keyCode,
394
+ });
395
+ debugLog('TYPE_CHAR', `keyUp sent OK (pressDelay=${pressDelay.toFixed(0)}ms)`);
396
+ } catch (err) {
397
+ debugLog('TYPE_CHAR', `keyUp FAILED`, String(err));
398
+ throw err;
399
+ }
400
+ }
401
+
402
+ /** Type text with human-like delays via CDP events */
403
+ export async function humanTypeText(
404
+ page: Page,
405
+ text: string,
406
+ profile?: HumanProfile
407
+ ): Promise<void> {
408
+ debugLog('TYPE_TEXT', `typing "${text}" (${text.length} chars)`);
409
+ let cdp: CDPSession;
410
+ try {
411
+ cdp = await page.context().newCDPSession(page);
412
+ debugLog('TYPE_TEXT', `CDP session created for typing`);
413
+ } catch (err) {
414
+ debugLog('TYPE_TEXT', `FAILED to create CDP session`, String(err));
415
+ throw err;
416
+ }
417
+ try {
418
+ for (let i = 0; i < text.length; i++) {
419
+ const char = text[i];
420
+ await cdpTypeChar(cdp, char);
421
+ const delay = typingDelay(profile);
422
+ debugLog('TYPE_TEXT', `char ${i+1}/${text.length} "${char}" typed, next delay=${delay.toFixed(0)}ms`);
423
+ await sleep(delay);
424
+ }
425
+ debugLog('TYPE_TEXT', `typing complete`);
426
+ } finally {
427
+ await cdp.detach().catch(() => {});
428
+ debugLog('TYPE_TEXT', `CDP session detached`);
429
+ }
430
+ }
431
+
432
+ // ==================== VISUAL CURSOR (CDP Overlay — zero DOM footprint) ====================
433
+
434
+ // Persistent CDP session per page for Overlay (must stay alive for overlay to render)
435
+ const overlaySessions = new WeakMap<Page, CDPSession>();
436
+
437
+ /** Get or create a persistent CDP session with Overlay enabled */
438
+ async function getOverlaySession(page: Page): Promise<CDPSession> {
439
+ let cdp = overlaySessions.get(page);
440
+ if (cdp) {
441
+ debugLog('OVERLAY', `reusing existing overlay session`);
442
+ return cdp;
443
+ }
444
+
445
+ debugLog('OVERLAY', `creating new overlay CDP session`);
446
+ cdp = await page.context().newCDPSession(page);
447
+
448
+ try {
449
+ // DOM must be enabled before Overlay
450
+ await cdp.send('DOM.enable');
451
+ debugLog('OVERLAY', `DOM.enable SUCCESS`);
452
+ await cdp.send('Overlay.enable');
453
+ debugLog('OVERLAY', `Overlay.enable SUCCESS`);
454
+ } catch (err) {
455
+ debugLog('OVERLAY', `Overlay.enable FAILED`, String(err));
456
+ }
457
+
458
+ overlaySessions.set(page, cdp);
459
+
460
+ // Clean up reference if session disconnects
461
+ cdp.on('detached' as any, () => {
462
+ debugLog('OVERLAY', `overlay session detached (cleanup)`);
463
+ overlaySessions.delete(page);
464
+ });
465
+
466
+ return cdp;
467
+ }
468
+
469
+ /** Show a cursor indicator at (x, y) via CDP Overlay — invisible to page JS */
470
+ export async function showCursorOverlay(page: Page, x: number, y: number): Promise<void> {
471
+ debugLog('CURSOR', `showCursorOverlay called at (${x},${y})`);
472
+ try {
473
+ const cdp = await getOverlaySession(page);
474
+ const params = {
475
+ x: Math.round(x) - 4,
476
+ y: Math.round(y) - 4,
477
+ width: 8,
478
+ height: 8,
479
+ color: { r: 255, g: 69, b: 58, a: 0.7 },
480
+ outlineColor: { r: 255, g: 255, b: 255, a: 0.9 },
481
+ };
482
+ debugLog('CURSOR', `sending Overlay.highlightRect`, params);
483
+ await cdp.send('Overlay.highlightRect', params);
484
+ debugLog('CURSOR', `highlightRect SUCCESS`);
485
+ } catch (err) {
486
+ debugLog('CURSOR', `highlightRect FAILED (attempt 1), retrying...`, String(err));
487
+ // Session may have died — clear and retry once
488
+ overlaySessions.delete(page);
489
+ try {
490
+ const cdp = await getOverlaySession(page);
491
+ await cdp.send('Overlay.highlightRect', {
492
+ x: Math.round(x) - 4,
493
+ y: Math.round(y) - 4,
494
+ width: 8,
495
+ height: 8,
496
+ color: { r: 255, g: 69, b: 58, a: 0.7 },
497
+ outlineColor: { r: 255, g: 255, b: 255, a: 0.9 },
498
+ });
499
+ debugLog('CURSOR', `highlightRect SUCCESS (retry)`);
500
+ } catch (err2) {
501
+ debugLog('CURSOR', `highlightRect FAILED (retry), giving up`, String(err2));
502
+ // Overlay not supported, ignore
503
+ }
504
+ }
505
+ }
506
+
507
+ /** Hide the cursor overlay */
508
+ export async function hideCursorOverlay(page: Page): Promise<void> {
509
+ debugLog('CURSOR', `hideCursorOverlay called`);
510
+ try {
511
+ const cdp = await getOverlaySession(page);
512
+ await cdp.send('Overlay.hideHighlight');
513
+ debugLog('CURSOR', `hideHighlight SUCCESS`);
514
+ } catch (err) {
515
+ debugLog('CURSOR', `hideHighlight FAILED`, String(err));
516
+ }
517
+ }
518
+
519
+ /** Ensure cursor overlay is ready (idempotent, no-op) */
520
+ export async function ensureCursor(page: Page): Promise<void> {
521
+ // CDP Overlay doesn't need pre-injection — it's enabled on first use
522
+ // This is a no-op kept for API compatibility with executor.ts
523
+ }