cdpilot 0.8.0 → 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/src/cdpilot.py CHANGED
@@ -14,7 +14,7 @@ Environment:
14
14
  CDPILOT_PROFILE Isolated browser profile directory
15
15
  """
16
16
 
17
- __version__ = "0.8.0"
17
+ __version__ = "0.9.0"
18
18
 
19
19
  import asyncio
20
20
  import atexit
@@ -236,7 +236,80 @@ window.__cdpilot_waitFor = function(selector, timeout) {
236
236
  # - Idempotent: guarded by window.__cdpilot_stealth flag.
237
237
  # - Fails silently per-patch; one failure cannot break the page.
238
238
  # - Opt-in via `cdpilot stealth on`. Default OFF for backward compatibility.
239
- STEALTH_JS = r"""
239
+ #
240
+ # THREE-TIER MODEL (crawl4ai-style regular -> stealth -> undetected):
241
+ # - regular : NO fingerprint patch injected at all. Cleanest, fastest,
242
+ # relies only on --disable-blink-features=AutomationControlled
243
+ # set at launch. Lowest entropy -> fewest leaks. Default tier.
244
+ # - stealth : STEALTH_JS_LIGHT - webdriver + chrome.runtime + permissions
245
+ # reconcile ONLY. Deliberately OMITS the plugin-array spoof,
246
+ # which Stealth Bench V1 proved is itself a leak source
247
+ # (bot.sannysoft.com surfaced garbage plugin filenames like
248
+ # "rCJMteXy" from the synthetic PluginArray). Light tier is a
249
+ # strict subset of FULL.
250
+ # - undetected : STEALTH_JS_FULL - the full body (light subset + plugins +
251
+ # Worker patch + WebGL vendor + hardwareConcurrency). Highest
252
+ # plausibility on naive checks but highest entropy; pair with
253
+ # behavioral entropy/humanize for the hardest targets.
254
+ #
255
+ # STEALTH_JS retains the FULL body for backward compatibility - every existing
256
+ # reference (`cdpilot stealth on`, adaptive escalation) maps to undetected.
257
+
258
+ # Light tier: the SAFE subset. webdriver + chrome.runtime + permissions only.
259
+ # No plugin spoofing (bench leak), no WebGL vendor override, no Worker wrap.
260
+ STEALTH_JS_LIGHT = r"""
261
+ (function() {
262
+ 'use strict';
263
+ if (window.__cdpilot_stealth) return;
264
+ try { Object.defineProperty(window, '__cdpilot_stealth', {value: true, writable: false, configurable: false}); } catch(e) {}
265
+
266
+ // 1) navigator.webdriver - only patch if it's actually `true`.
267
+ try {
268
+ var wdValue;
269
+ try { wdValue = navigator.webdriver; } catch (_) {}
270
+ if (wdValue === true) {
271
+ Object.defineProperty(Navigator.prototype, 'webdriver', {
272
+ get: function() { return false; },
273
+ configurable: true
274
+ });
275
+ }
276
+ } catch (e) {}
277
+
278
+ // 2) window.chrome - real Chrome exposes chrome.runtime even without extensions
279
+ try {
280
+ if (!window.chrome) {
281
+ window.chrome = {};
282
+ }
283
+ if (!window.chrome.runtime) {
284
+ window.chrome.runtime = {
285
+ OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {},
286
+ PlatformNackArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}
287
+ };
288
+ }
289
+ if (typeof window.chrome.app === 'undefined') {
290
+ window.chrome.app = { isInstalled: false, InstallState: {}, RunningState: {} };
291
+ }
292
+ } catch (e) {}
293
+
294
+ // 3) Permissions.query - reconcile with Notification.permission (classic tell)
295
+ try {
296
+ if (navigator.permissions && navigator.permissions.query) {
297
+ var origQuery = navigator.permissions.query.bind(navigator.permissions);
298
+ navigator.permissions.query = function(p) {
299
+ if (p && p.name === 'notifications') {
300
+ return Promise.resolve({ state: Notification.permission, onchange: null });
301
+ }
302
+ return origQuery(p);
303
+ };
304
+ }
305
+ } catch (e) {}
306
+ })();
307
+ """
308
+
309
+ # Full tier (a.k.a. "undetected"): the complete body. Light subset + plugin
310
+ # array + Worker patch + WebGL vendor + hardwareConcurrency. STEALTH_JS is kept
311
+ # as an alias of FULL so every legacy reference still resolves.
312
+ STEALTH_JS_FULL = r"""
240
313
  (function() {
241
314
  'use strict';
242
315
  if (window.__cdpilot_stealth) return;
@@ -404,6 +477,11 @@ STEALTH_JS = r"""
404
477
  })();
405
478
  """
406
479
 
480
+ # Backward-compat alias: STEALTH_JS == the FULL (undetected) body. Every legacy
481
+ # reference (`cdpilot stealth on`, adaptive escalation, navigate inject) keeps
482
+ # resolving to the full patch set, exactly as before the three-tier split.
483
+ STEALTH_JS = STEALTH_JS_FULL
484
+
407
485
  # ─── CAPTCHA Detection ────────────────────────────────────────────────────────
408
486
  # Read-only DOM probe. Detects common CAPTCHA/challenge providers post-navigation.
409
487
  # Returns JSON string: {"detected": bool, "types": [...], "details": [...]}
@@ -461,6 +539,14 @@ CAPTCHA_DETECT_JS = r"""
461
539
  add('geetest', 'GeeTest');
462
540
  }
463
541
 
542
+ // Amazon classic image CAPTCHA ("Type the characters you see in this image")
543
+ // Solvable locally with the optional amazoncaptcha library (image OCR).
544
+ var amzInput = document.querySelector('#captchacharacters');
545
+ var amzImg = document.querySelector('img[src*="opfcaptcha"], img[src*="captcha"][src*="images-na.ssl-images-amazon.com"], form[action*="/errors/validateCaptcha"] img');
546
+ if (amzInput && amzImg) {
547
+ add('amazon-classic', 'Amazon', { img_src: amzImg.src, input_id: amzInput.id || 'captchacharacters' });
548
+ }
549
+
464
550
  return JSON.stringify(result);
465
551
  } catch (e) {
466
552
  return JSON.stringify({ detected: false, error: String(e) });
@@ -468,9 +554,127 @@ CAPTCHA_DETECT_JS = r"""
468
554
  })()
469
555
  """
470
556
 
557
+ # ─── Progressive-Resilience Escalation Ladder ───
558
+ # Real sites stack defenses incrementally (sahibinden.com pattern: random
559
+ # captcha -> rate-limit -> login-wall -> SMS/OTP). CAPTCHA_DETECT_JS only sees
560
+ # the captcha rung. FRICTION_DETECT_JS detects the OTHER friction rungs purely
561
+ # from the DOM (read-only) and reports the HIGHEST one present. Bilingual
562
+ # (Turkish + English) keyword heuristics — TR sites are first-class.
563
+ #
564
+ # Ladder (low -> high):
565
+ # none normal page
566
+ # rate_limited HTTP 429 markers, "too many requests" / "çok fazla istek"
567
+ # soft_captcha (handled by CAPTCHA_DETECT_JS, merged in _detect_friction)
568
+ # login_wall login form gating the real content (sign in / giriş yap)
569
+ # otp_sms verification-code / OTP / SMS input
570
+ # hard_block access denied / banned / 403 forbidden, no content
571
+ FRICTION_DETECT_JS = r"""
572
+ (function() {
573
+ try {
574
+ var out = { level: 'none', signals: [], detail: '' };
575
+ var bodyText = (document.body ? (document.body.innerText || '') : '').toLowerCase();
576
+ var titleText = (document.title || '').toLowerCase();
577
+ var hay = titleText + ' \n ' + bodyText;
578
+ var sample = hay.slice(0, 6000);
579
+ var hasAny = function(words) {
580
+ for (var i = 0; i < words.length; i++) {
581
+ if (sample.indexOf(words[i]) >= 0) return words[i];
582
+ }
583
+ return null;
584
+ };
585
+
586
+ // ── hard_block (highest) ──
587
+ var blockWords = [
588
+ 'access denied', 'access to this page has been denied', 'you have been blocked',
589
+ 'permanently banned', 'ip has been banned', '403 forbidden', 'forbidden',
590
+ 'erişim engellendi', 'erişiminiz engellendi', 'engellendiniz', 'yasaklandı',
591
+ 'reddedildi', 'banlandı'
592
+ ];
593
+ var blockHit = hasAny(blockWords);
594
+ // Only treat as a hard block when the page has almost no real content
595
+ // (block pages are typically tiny). Avoids false positives on articles
596
+ // that merely mention these words.
597
+ var thinPage = bodyText.replace(/\s+/g, ' ').trim().length < 600;
598
+
599
+ // ── otp_sms ──
600
+ var otpWords = [
601
+ 'verification code', 'one-time code', 'one time password', 'otp',
602
+ 'sms code', 'code sent to your phone', 'enter the code',
603
+ 'doğrulama kodu', 'onay kodu', 'tek kullanımlık', 'telefonunuza gönderilen',
604
+ 'sms ile gönderilen', 'sms doğrulama', 'kodu girin'
605
+ ];
606
+ var otpInput = document.querySelector(
607
+ 'input[autocomplete="one-time-code"], input[name*="otp" i], input[id*="otp" i], ' +
608
+ 'input[name*="code" i][maxlength], input[name*="sms" i], input[name*="verification" i], ' +
609
+ 'input[id*="verification" i], input[name*="dogrulama" i], input[id*="dogrulama" i]'
610
+ );
611
+ var otpWordHit = hasAny(otpWords);
612
+
613
+ // ── login_wall ──
614
+ var pwInput = document.querySelector('input[type="password"]');
615
+ var loginForm = document.querySelector(
616
+ 'form[action*="login" i], form[action*="signin" i], form[action*="giris" i], form[id*="login" i]'
617
+ );
618
+ var loginWords = [
619
+ 'sign in', 'log in', 'login to continue', 'please sign in', 'you must be logged in',
620
+ 'create an account to continue', 'sign in to continue',
621
+ 'giriş yap', 'oturum aç', 'giriş yapın', 'üye girişi', 'devam etmek için giriş',
622
+ 'giriş yapmalısınız', 'lütfen giriş yapın', 'hesabınıza giriş'
623
+ ];
624
+ var loginWordHit = hasAny(loginWords);
625
+
626
+ // ── rate_limited ──
627
+ var rateWords = [
628
+ 'too many requests', 'rate limit', 'rate limited', 'slow down',
629
+ 'you are being rate limited', 'request limit', 'try again later',
630
+ 'çok fazla istek', 'çok sık istek', 'lütfen yavaşlayın', 'yavaşlayın',
631
+ 'istek sınırı', 'çok hızlı', 'daha sonra tekrar deneyin', 'güvenlik doğrulaması'
632
+ ];
633
+ var rateWordHit = hasAny(rateWords);
634
+ // 429 surfaced into DOM (some sites print the status), or retry-after hint
635
+ var rate429 = /\b429\b/.test(sample) && (sample.indexOf('request') >= 0 || sample.indexOf('istek') >= 0 || rateWordHit);
636
+ var retryAfter = /retry[\s-]?after/.test(sample);
637
+
638
+ // Decide highest rung present.
639
+ if (blockHit && thinPage) {
640
+ out.level = 'hard_block';
641
+ out.signals.push('keyword:' + blockHit);
642
+ out.detail = 'Access denied / banned page with no real content.';
643
+ } else if (otpWordHit || otpInput) {
644
+ out.level = 'otp_sms';
645
+ if (otpWordHit) out.signals.push('keyword:' + otpWordHit);
646
+ if (otpInput) out.signals.push('input:otp');
647
+ out.detail = 'SMS/OTP verification prompt detected.';
648
+ } else if ((pwInput || loginForm) && loginWordHit) {
649
+ out.level = 'login_wall';
650
+ out.signals.push('keyword:' + loginWordHit);
651
+ if (pwInput) out.signals.push('input:password');
652
+ if (loginForm) out.signals.push('form:login');
653
+ out.detail = 'Login wall gating the page content.';
654
+ } else if (loginWordHit && loginForm) {
655
+ out.level = 'login_wall';
656
+ out.signals.push('keyword:' + loginWordHit);
657
+ out.signals.push('form:login');
658
+ out.detail = 'Login wall gating the page content.';
659
+ } else if (rateWordHit || rate429 || retryAfter) {
660
+ out.level = 'rate_limited';
661
+ if (rateWordHit) out.signals.push('keyword:' + rateWordHit);
662
+ if (rate429) out.signals.push('status:429');
663
+ if (retryAfter) out.signals.push('hint:retry-after');
664
+ out.detail = 'Rate-limit signal detected on page.';
665
+ }
666
+
667
+ return JSON.stringify(out);
668
+ } catch (e) {
669
+ return JSON.stringify({ level: 'none', signals: [], detail: '', error: String(e) });
670
+ }
671
+ })()
672
+ """
673
+
471
674
  PROXY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'proxy.json')
472
675
  HEADLESS_CONFIG_FILE = os.path.join(PROFILE_DIR, 'headless.json')
473
676
  STEALTH_CONFIG_FILE = os.path.join(PROFILE_DIR, 'stealth.json')
677
+ MODE_CONFIG_FILE = os.path.join(PROFILE_DIR, 'mode.json')
474
678
  BLOCK_CONFIG_FILE = os.path.join(PROFILE_DIR, 'block.json')
475
679
  CAPTCHA_PROVIDERS_FILE = os.path.join(CDPILOT_HOME, 'captcha-providers.json')
476
680
  CAPTCHA_AUTO_FILE = os.path.join(PROFILE_DIR, 'captcha-auto.json')
@@ -480,6 +684,10 @@ ADAPTIVE_CONFIG_FILE = os.path.join(PROFILE_DIR, 'adaptive.json')
480
684
  ENTROPY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'entropy.json')
481
685
  DOWNLOAD_CONFIG_FILE = os.path.join(PROFILE_DIR, 'download-config.json')
482
686
  SESSION_FILE = os.path.join(PROFILE_DIR, 'sessions.json')
687
+ # Tabs that cdpilot itself opened (via go / new-tab / smart-click). The smart
688
+ # `close` command only touches these — user tabs opened manually in the same
689
+ # isolated profile are left alone.
690
+ OWNED_TABS_FILE = os.path.join(PROFILE_DIR, 'owned-tabs.json')
483
691
  COOKIES_DIR = os.path.join(CDPILOT_HOME, 'cookies')
484
692
  COOKIES_AUTO_CONFIG_FILE = os.path.join(PROFILE_DIR, 'cookies-auto.json')
485
693
  CF_CLEARANCE_COOKIES = frozenset({'cf_clearance', '__cf_bm', '_cfuvid'})
@@ -1030,6 +1238,9 @@ def _create_session_window():
1030
1238
  "last_used": time.time(),
1031
1239
  }
1032
1240
  _save_sessions(sessions)
1241
+ # Freshly created by cdpilot -> owned. (The reuse branch above does not
1242
+ # mark, since that tab may have been opened by the user.)
1243
+ _mark_owned_tab(target_id)
1033
1244
 
1034
1245
  return target_id
1035
1246
 
@@ -1405,11 +1616,16 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
1405
1616
  # as long as this connection lives — which is exactly the window we
1406
1617
  # need (until Page.loadEventFired below). Stealth must run before
1407
1618
  # any page script inspects navigator; addScript guarantees that.
1408
- stealth_active = get_stealth_config()
1409
- if stealth_active:
1619
+ #
1620
+ # Three-tier selection: regular -> inject nothing, stealth -> LIGHT,
1621
+ # undetected -> FULL. get_mode_config() folds in the legacy stealth
1622
+ # toggle and the CDPILOT_STEALTH env var (set during adaptive
1623
+ # escalation) so existing behavior is preserved.
1624
+ stealth_source = stealth_js_for_tier(get_mode_config())
1625
+ if stealth_source:
1410
1626
  await ws.send(json.dumps({
1411
1627
  "id": 50, "method": "Page.addScriptToEvaluateOnNewDocument",
1412
- "params": {"source": STEALTH_JS}
1628
+ "params": {"source": stealth_source}
1413
1629
  }))
1414
1630
 
1415
1631
  # Apply request blocking BEFORE navigate, on this same WS session.
@@ -1959,6 +2175,105 @@ def get_headless_config():
1959
2175
  return False
1960
2176
 
1961
2177
 
2178
+ # ─── Three-tier stealth mode (crawl4ai-style escalation) ──────────────────────
2179
+ # Tiers, weakest fingerprint footprint -> strongest spoof:
2180
+ # regular -> inject NOTHING (cleanest, default)
2181
+ # stealth -> STEALTH_JS_LIGHT (webdriver/chrome.runtime/permissions only)
2182
+ # undetected -> STEALTH_JS_FULL (light + plugins + WebGL + Worker)
2183
+ MODE_TIERS = ('regular', 'stealth', 'undetected')
2184
+ DEFAULT_MODE_TIER = 'regular'
2185
+
2186
+
2187
+ def get_mode_config():
2188
+ """Return the active stealth tier as a string in MODE_TIERS.
2189
+
2190
+ Resolution order: CDPILOT_MODE env -> mode.json -> legacy stealth on/off
2191
+ -> DEFAULT_MODE_TIER. Legacy `cdpilot stealth on` (no mode.json present)
2192
+ maps to 'undetected' so the old binary toggle still produces the full
2193
+ patch set; `stealth off` (or absent) keeps 'regular'.
2194
+ """
2195
+ env = os.environ.get('CDPILOT_MODE', '').strip().lower()
2196
+ if env in MODE_TIERS:
2197
+ return env
2198
+ if os.path.exists(MODE_CONFIG_FILE):
2199
+ try:
2200
+ with open(MODE_CONFIG_FILE) as f:
2201
+ tier = json.load(f).get('tier', DEFAULT_MODE_TIER)
2202
+ if tier in MODE_TIERS:
2203
+ return tier
2204
+ except (OSError, ValueError):
2205
+ pass
2206
+ # Backwards-compat: no explicit mode set — honor the legacy stealth toggle.
2207
+ # stealth on -> undetected (full), stealth off -> regular (clean).
2208
+ return 'undetected' if get_stealth_config() else DEFAULT_MODE_TIER
2209
+
2210
+
2211
+ def set_mode_config(tier):
2212
+ """Persist the active tier to mode.json. Returns the normalized tier."""
2213
+ tier = (tier or '').strip().lower()
2214
+ if tier not in MODE_TIERS:
2215
+ raise ValueError(f"invalid tier: {tier!r}")
2216
+ _atomic_write_json(MODE_CONFIG_FILE, {'tier': tier})
2217
+ return tier
2218
+
2219
+
2220
+ def stealth_js_for_tier(tier):
2221
+ """Return the fingerprint patch JS for a tier, or None for 'regular'.
2222
+
2223
+ regular -> None (inject nothing)
2224
+ stealth -> STEALTH_JS_LIGHT
2225
+ undetected -> STEALTH_JS_FULL
2226
+ """
2227
+ if tier == 'stealth':
2228
+ return STEALTH_JS_LIGHT
2229
+ if tier == 'undetected':
2230
+ return STEALTH_JS_FULL
2231
+ return None
2232
+
2233
+
2234
+ def cmd_mode(tier=None):
2235
+ """Get or set the three-tier stealth mode (regular | stealth | undetected).
2236
+
2237
+ Usage:
2238
+ cdpilot mode # show current tier + what it injects
2239
+ cdpilot mode regular # no fingerprint patch (cleanest, fastest)
2240
+ cdpilot mode stealth # light patch: webdriver/chrome.runtime/perms
2241
+ cdpilot mode undetected # full patch: + plugins + WebGL + Worker
2242
+
2243
+ Default: regular. Stealth Bench V1 found that the full patch set ALONE
2244
+ lowered scores (-6.3p) because the synthetic plugin array leaks, while
2245
+ light host-learning gained (+6.3p). The 'stealth' tier omits plugin
2246
+ spoofing for this reason. Escalate to 'undetected' only for hard targets.
2247
+ Effect applies on the NEXT navigation. Env override: CDPILOT_MODE=<tier>.
2248
+ """
2249
+ if tier is None or tier.strip().lower() in ('', 'status'):
2250
+ current = get_mode_config()
2251
+ patch = {
2252
+ 'regular': 'none (no fingerprint patch — cleanest)',
2253
+ 'stealth': 'STEALTH_JS_LIGHT (webdriver, chrome.runtime, permissions)',
2254
+ 'undetected': 'STEALTH_JS_FULL (light + plugins + WebGL + Worker)',
2255
+ }[current]
2256
+ print(f'Mode: {current}')
2257
+ print(f' Injects: {patch}')
2258
+ return
2259
+ t = tier.strip().lower()
2260
+ if t not in MODE_TIERS:
2261
+ print(f"Invalid tier: {tier}. Use one of: {', '.join(MODE_TIERS)}.", file=sys.stderr)
2262
+ sys.exit(1)
2263
+ set_mode_config(t)
2264
+ # Keep the legacy stealth toggle coherent so `stealth status` and any code
2265
+ # still reading get_stealth_config() agree with the tier:
2266
+ # regular -> stealth off
2267
+ # stealth -> stealth on (a patch IS injected, just the light one)
2268
+ # undetected-> stealth on
2269
+ try:
2270
+ _atomic_write_json(STEALTH_CONFIG_FILE, {'stealth': t != 'regular'})
2271
+ except Exception:
2272
+ pass
2273
+ print(f'Mode: {t}')
2274
+ print('Effect applies on next navigation (`cdpilot go <url>`).')
2275
+
2276
+
1962
2277
  def get_stealth_config():
1963
2278
  """Return whether stealth fingerprint patches are enabled.
1964
2279
 
@@ -2012,6 +2327,31 @@ def _sanitize_profile_state(profile_dir):
2012
2327
 
2013
2328
  # ─── Commands ───
2014
2329
 
2330
+ def _minimize_browser_window():
2331
+ """Minimize the browser window via CDP (Browser.setWindowBounds).
2332
+
2333
+ Reliable on macOS where negative --window-position is clamped back on
2334
+ screen. Uses cdpilot's own async cdp_send over the browser-level WS —
2335
+ no new dependency. Best-effort: any failure is swallowed by the caller.
2336
+ """
2337
+ ver = cdp_get('/json/version') or {}
2338
+ ws_url = ver.get('webSocketDebuggerUrl')
2339
+ if not ws_url:
2340
+ return
2341
+
2342
+ async def _do():
2343
+ r = await cdp_send(ws_url, [(1, "Browser.getWindowForTarget", {})])
2344
+ win_id = ((r.get(1) or {}).get("result") or {}).get("windowId")
2345
+ if win_id is None:
2346
+ win_id = 1
2347
+ await cdp_send(ws_url, [(2, "Browser.setWindowBounds", {
2348
+ "windowId": win_id,
2349
+ "bounds": {"windowState": "minimized"},
2350
+ })])
2351
+
2352
+ asyncio.run(_do())
2353
+
2354
+
2015
2355
  def cmd_launch():
2016
2356
  """Launch the browser with CDP enabled (isolated session — does not touch existing browser)."""
2017
2357
  global CHROME_BIN, CDP_PORT, CDP_BASE
@@ -2098,6 +2438,12 @@ def cmd_launch():
2098
2438
  'InterestFeedContentSuggestions', 'CalculateNativeWinOcclusion',
2099
2439
  'PasswordManagerOnboarding', 'AutofillServerCommunication',
2100
2440
  ]),
2441
+ # Anti-bot leak fix (2026-05-31): Chrome's blink runtime exposes an
2442
+ # "automation controlled" flag when --remote-debugging-port is set;
2443
+ # Patchright/nodriver disable this. Even though navigator.webdriver
2444
+ # already reads false on vanilla Brave/Chrome, deeper detectors
2445
+ # (Cloudflare/Datadome behavioral) probe the blink flag directly.
2446
+ '--disable-blink-features=AutomationControlled',
2101
2447
  # ─── Chromium performance flags ───
2102
2448
  '--disable-background-networking',
2103
2449
  '--disable-background-timer-throttling',
@@ -2128,6 +2474,17 @@ def cmd_launch():
2128
2474
  '--new-window', 'about:blank',
2129
2475
  ]
2130
2476
 
2477
+ # Off-screen window placement (opt-in). Keeps the browser HEADED (anti-bot
2478
+ # detection still sees a real window / GPU) but parks it far off the visible
2479
+ # desktop so it never steals focus or pops in front of the user — ideal for
2480
+ # long bench runs on a workstation. CDPILOT_OFFSCREEN=1 or a custom
2481
+ # CDPILOT_WINDOW_POSITION="x,y". No effect on headless.
2482
+ _win_pos = os.environ.get('CDPILOT_WINDOW_POSITION', '')
2483
+ if not _win_pos and os.environ.get('CDPILOT_OFFSCREEN') in ('1', 'true', 'yes', 'on'):
2484
+ _win_pos = '-3000,-3000'
2485
+ if _win_pos:
2486
+ chrome_args.append(f'--window-position={_win_pos}')
2487
+
2131
2488
  # Dev extensions
2132
2489
  dev_exts = get_dev_extensions()
2133
2490
  valid_exts = [p for p in dev_exts if os.path.isdir(p)]
@@ -2154,6 +2511,16 @@ def cmd_launch():
2154
2511
  if cdp_get('/json/version'):
2155
2512
  if PROJECT_ID:
2156
2513
  _register_project(PROJECT_ID, CDP_PORT, PROFILE_DIR, pid=proc.pid)
2514
+ # Off-screen / minimized placement (opt-in). Keeps the browser
2515
+ # HEADED (anti-bot still sees a real GPU-backed window) but parks it
2516
+ # out of the way so long bench runs never steal focus or pop in
2517
+ # front of the user. macOS clamps negative --window-position, so we
2518
+ # also minimize via CDP (Browser.setWindowBounds) which is reliable.
2519
+ if os.environ.get('CDPILOT_OFFSCREEN') in ('1', 'true', 'yes', 'on'):
2520
+ try:
2521
+ _minimize_browser_window()
2522
+ except Exception:
2523
+ pass
2157
2524
  proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
2158
2525
  print(f'CDP ready! (port {CDP_PORT}){proj_label}')
2159
2526
  return
@@ -2177,6 +2544,9 @@ async def cmd_go(url):
2177
2544
  cmd_launch()
2178
2545
 
2179
2546
  ws, page = get_page_ws()
2547
+ # Mark this tab as cdpilot-owned so a later `close` knows it can shut it.
2548
+ if isinstance(page, dict):
2549
+ _mark_owned_tab(page.get("id"))
2180
2550
 
2181
2551
  try:
2182
2552
  from urllib.parse import urlparse
@@ -2188,12 +2558,20 @@ async def cmd_go(url):
2188
2558
  adaptive_stealth = False
2189
2559
  is_known_hostile = _adaptive_host_requires_stealth(url)
2190
2560
 
2191
- # Adaptive mode: if this hostname is in the learned stealth-required list,
2192
- # temporarily enable stealth for the navigation that's about to happen.
2193
- if is_known_hostile and not get_stealth_config():
2194
- os.environ['CDPILOT_STEALTH'] = '1'
2561
+ # Adaptive mode (tier-aware): if we learned a tier for this host, start the
2562
+ # navigation at that tier via CDPILOT_MODE. Falls back to the legacy
2563
+ # stealth_hosts list (-> 'stealth' tier) for hosts learned before tiers
2564
+ # existed. CDPILOT_MODE takes precedence in get_mode_config().
2565
+ learned_tier = _adaptive_host_tier(url)
2566
+ if learned_tier and learned_tier != 'regular' and get_mode_config() == 'regular':
2567
+ os.environ['CDPILOT_MODE'] = learned_tier
2195
2568
  adaptive_stealth = True
2196
- sys.stderr.write(f"🛡️ Adaptive: enabling stealth for known-hostile host\n")
2569
+ sys.stderr.write(f"🛡️ Adaptive: starting at learned tier '{learned_tier}' for known-hostile host\n")
2570
+ elif is_known_hostile and get_mode_config() == 'regular':
2571
+ # Legacy stealth_hosts entry with no tier learned yet — start at light.
2572
+ os.environ['CDPILOT_MODE'] = 'stealth'
2573
+ adaptive_stealth = True
2574
+ sys.stderr.write(f"🛡️ Adaptive: enabling stealth tier for known-hostile host\n")
2197
2575
 
2198
2576
  # Fix 1: Per-task context isolation (opt-in via CDPILOT_ADAPTIVE_FRESH_CONTEXT=1) — spawn a fresh BrowserContext when
2199
2577
  # adaptive is on and we know this host is hostile. Prevents cross-task
@@ -2231,6 +2609,44 @@ async def cmd_go(url):
2231
2609
  except Exception:
2232
2610
  pass
2233
2611
 
2612
+ # Progressive-resilience friction probe (non-captcha rungs).
2613
+ # rate_limited -> exponential backoff + re-nav (bounded). login/otp/hard
2614
+ # -> stderr handoff warning only (NO autonomous bypass — ethics line).
2615
+ # The captcha rung is left to the dedicated probe below.
2616
+ try:
2617
+ fr = await _detect_friction(active_ws)
2618
+ fr_level = fr.get('level', 'none')
2619
+ if fr_level == 'rate_limited' and _friction_backoff_enabled():
2620
+ attempt = 0
2621
+ while fr_level == 'rate_limited' and attempt < _friction_max_retry():
2622
+ wait_s = _friction_backoff_seconds(attempt)
2623
+ sys.stderr.write(
2624
+ f"⏳ Rate-limit tespit edildi ({','.join(fr.get('signals', [])) or '?'}); "
2625
+ f"{wait_s}s backoff, retry {attempt + 1}/{_friction_max_retry()}...\n")
2626
+ if expected_host:
2627
+ _adaptive_remember_host(expected_host)
2628
+ await asyncio.sleep(wait_s)
2629
+ await navigate_collect(active_ws, url)
2630
+ fr = await _detect_friction(active_ws)
2631
+ fr_level = fr.get('level', 'none')
2632
+ attempt += 1
2633
+ if fr_level == 'rate_limited':
2634
+ sys.stderr.write("⚠️ Rate-limit backoff sonrası hâlâ sınırlı. Daha sonra deneyin.\n")
2635
+ else:
2636
+ sys.stderr.write("✅ Rate-limit temizlendi.\n")
2637
+ elif fr_level in ('login_wall', 'otp_sms', 'hard_block'):
2638
+ act = _friction_action(fr_level)
2639
+ payload = {'action': act['action'], 'url': url, 'level': fr_level,
2640
+ 'signals': fr.get('signals', [])}
2641
+ if act.get('backoff_suggested'):
2642
+ payload['backoff_suggested'] = True
2643
+ # Human handoff: warn + emit JSON line, but DO NOT block the
2644
+ # navigation (caller may still want the partial content).
2645
+ sys.stderr.write(f"{act['message']}\n")
2646
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
2647
+ except Exception:
2648
+ pass
2649
+
2234
2650
  # Post-navigation CAPTCHA probe. Non-blocking: stderr warning only.
2235
2651
  try:
2236
2652
  info = await _detect_captcha(active_ws)
@@ -2238,20 +2654,26 @@ async def cmd_go(url):
2238
2654
  types = ",".join(info.get("types", [])) or "unknown"
2239
2655
  sys.stderr.write(f"⚠️ CAPTCHA tespit edildi ({types}). Çözüm için: cdpilot captcha-wait\n")
2240
2656
 
2241
- # Adaptive escalation: remember this host so the next visit
2242
- # starts in stealth. Re-nav is gated to once-per-call.
2657
+ # Adaptive escalation (tier-aware): bump the host one tier up
2658
+ # (regular -> stealth -> undetected), remember it, and re-nav
2659
+ # once at the new tier. Re-nav is gated to once-per-call.
2243
2660
  if cfg['enabled']:
2661
+ current_tier = get_mode_config()
2662
+ next_tier = _escalate_tier(current_tier)
2244
2663
  if expected_host:
2245
2664
  _adaptive_remember_host(expected_host)
2246
- sys.stderr.write(f"🛡️ Adaptive: remembered {expected_host} as stealth-required\n")
2665
+ _adaptive_remember_host_tier(expected_host, next_tier)
2666
+ sys.stderr.write(f"🛡️ Adaptive: remembered {expected_host} at tier '{next_tier}'\n")
2247
2667
  captcha_types = info.get('types', [])
2248
2668
  _adaptive_remember_host_entropy(expected_host, captcha_types)
2249
2669
  needs_entropy = any(CAPTCHA_ENTROPY_REQUIRED.get(t, True) for t in captcha_types)
2250
2670
  if needs_entropy:
2251
2671
  sys.stderr.write(f"🧬 Adaptive: per-host entropy enabled for {expected_host} ({','.join(captcha_types)})\n")
2252
- if not adaptive_stealth and not get_stealth_config():
2253
- sys.stderr.write("🛡️ Adaptive: retrying once with stealth on...\n")
2254
- os.environ['CDPILOT_STEALTH'] = '1'
2672
+ # Only retry if escalation actually changes the tier (not
2673
+ # already at the undetected ceiling for this navigation).
2674
+ if next_tier != current_tier:
2675
+ sys.stderr.write(f"🛡️ Adaptive: retrying once at tier '{next_tier}'...\n")
2676
+ os.environ['CDPILOT_MODE'] = next_tier
2255
2677
  # Fix 3: Idempotent re-nav — skip if already on target.
2256
2678
  current_host = await _adaptive_current_host(active_ws)
2257
2679
  norm = lambda h: h[4:] if h.startswith("www.") else h
@@ -2259,12 +2681,12 @@ async def cmd_go(url):
2259
2681
  sys.stderr.write("🛡️ Adaptive: already on target host, skip re-nav\n")
2260
2682
  else:
2261
2683
  await navigate_collect(active_ws, url)
2262
- # Re-probe after stealth re-nav
2684
+ # Re-probe after escalated re-nav
2263
2685
  info2 = await _detect_captcha(active_ws)
2264
2686
  if info2.get("detected"):
2265
- sys.stderr.write("⚠️ Adaptive: CAPTCHA still present after stealth retry. Manual solve needed.\n")
2687
+ sys.stderr.write(f"⚠️ Adaptive: CAPTCHA still present after '{next_tier}' retry. Manual solve needed.\n")
2266
2688
  else:
2267
- sys.stderr.write("✅ Adaptive: CAPTCHA cleared with stealth.\n")
2689
+ sys.stderr.write(f"✅ Adaptive: CAPTCHA cleared at tier '{next_tier}'.\n")
2268
2690
  # Captcha solver auto-mode: if provider configured + auto on, attempt solve
2269
2691
  try:
2270
2692
  await _captcha_auto_solve_if_enabled(active_ws, info, url)
@@ -2773,6 +3195,188 @@ async def _humanize_click(ws_url, x, y):
2773
3195
  await asyncio.sleep(r.uniform(0.08, 0.20))
2774
3196
 
2775
3197
 
3198
+ # ─── PerimeterX / HUMAN "Press & Hold" solver ───────────────────────────────
3199
+ # PerimeterX (HUMAN Security) gates content behind a button the user must press
3200
+ # AND HOLD for several seconds. The detector measures the mouse-hold *behaviour*
3201
+ # (press duration, micro-tremor during the hold, release timing) to tell a human
3202
+ # hand from a bot — it is NOT a token challenge, so there is nothing to fetch or
3203
+ # inject. The only way through is to emit a real, human-shaped press->hold->release
3204
+ # gesture. We reuse the existing humanized mouse path + Input.dispatchMouseEvent
3205
+ # (zero new deps, pure CDP) and add the natural hand-tremor PerimeterX looks for.
3206
+
3207
+ # JS that locates the press-and-hold target and returns its viewport centre.
3208
+ # Tries the canonical #px-captcha / [class*="px-captcha"] hooks first, then any
3209
+ # element whose visible text matches "press & hold" / "press and hold" / the
3210
+ # Turkish "basili tut". Returns JSON: {found, cx, cy, w, h, how} or {found:false}.
3211
+ PRESS_HOLD_FIND_JS = r"""
3212
+ (function(userSel) {
3213
+ try {
3214
+ function centre(el, how) {
3215
+ if (!el) return null;
3216
+ try { el.scrollIntoView({behavior: 'instant', block: 'center'}); } catch (e) {}
3217
+ var r = el.getBoundingClientRect();
3218
+ if (r.width < 1 || r.height < 1) return null;
3219
+ return JSON.stringify({
3220
+ found: true,
3221
+ cx: Math.round(r.left + r.width / 2),
3222
+ cy: Math.round(r.top + r.height / 2),
3223
+ w: Math.round(r.width), h: Math.round(r.height), how: how
3224
+ });
3225
+ }
3226
+ if (userSel) {
3227
+ var u = document.querySelector(userSel);
3228
+ var cu = centre(u, 'user-selector');
3229
+ if (cu) return cu;
3230
+ }
3231
+ // Canonical PerimeterX hooks.
3232
+ var px = document.querySelector('#px-captcha, [class*="px-captcha"]');
3233
+ if (px) {
3234
+ // The visible hold target is often an inner button/div; prefer a child
3235
+ // with real size, else the container itself.
3236
+ var inner = px.querySelector('div[role="button"], button, [tabindex], div');
3237
+ var ci = centre(inner, 'px-inner');
3238
+ if (ci) return ci;
3239
+ var cc = centre(px, 'px-container');
3240
+ if (cc) return cc;
3241
+ }
3242
+ // Text-based fallback: any small interactive element saying press & hold.
3243
+ var needles = ['press & hold', 'press and hold', 'press &amp; hold',
3244
+ 'basili tut', 'basılı tut', 'press hold'];
3245
+ var cands = document.querySelectorAll(
3246
+ 'button, [role="button"], div, span, a, p');
3247
+ for (var i = 0; i < cands.length; i++) {
3248
+ var el = cands[i];
3249
+ var t = (el.textContent || '').toLowerCase().trim();
3250
+ if (!t || t.length > 60) continue;
3251
+ for (var j = 0; j < needles.length; j++) {
3252
+ if (t.indexOf(needles[j]) >= 0) {
3253
+ var ct = centre(el, 'text-match');
3254
+ if (ct) return ct;
3255
+ }
3256
+ }
3257
+ }
3258
+ return JSON.stringify({ found: false });
3259
+ } catch (e) {
3260
+ return JSON.stringify({ found: false, error: String(e) });
3261
+ }
3262
+ })
3263
+ """
3264
+
3265
+
3266
+ def _press_hold_duration_ms():
3267
+ """Gaussian-randomized hold duration in ms. mu~4000, clamped 3000..7000.
3268
+
3269
+ PerimeterX rejects a fixed duration (machine-like). We draw from a Gaussian
3270
+ so each attempt looks individually human, then clamp to the band the widget
3271
+ actually accepts.
3272
+ """
3273
+ return int(_gauss(4000, 900, 3000, 7000))
3274
+
3275
+
3276
+ async def _press_hold_gesture(ws_url, x, y, hold_ms):
3277
+ """Emit one real press->hold(jitter)->release gesture at (x, y) over CDP.
3278
+
3279
+ 1. Bezier-humanized mouse move to the target (reuses _humanize_mouse_move).
3280
+ 2. Input.dispatchMouseEvent mousePressed (button:left) at the target.
3281
+ 3. Hold loop: every 100-200ms emit a mouseMoved with +/-1-2px jitter WHILE the
3282
+ left button stays pressed -- this is the natural hand-tremor PerimeterX
3283
+ inspects. Total loop time == hold_ms.
3284
+ 4. Input.dispatchMouseEvent mouseReleased.
3285
+ Returns the number of micro-jitter moves dispatched during the hold.
3286
+ """
3287
+ import random as _r
3288
+ r = _r.Random(int(_ENTROPY_SEED)) if _ENTROPY_SEED else _r.Random()
3289
+ # Land the cursor on the target with a human path + small final offset.
3290
+ await _humanize_mouse_move(ws_url, x, y)
3291
+ await asyncio.sleep(r.uniform(0.05, 0.18))
3292
+ # Press and hold the left button at the target.
3293
+ await cdp_send(ws_url, [(981, "Input.dispatchMouseEvent", {
3294
+ "type": "mousePressed", "x": x, "y": y,
3295
+ "button": "left", "buttons": 1, "clickCount": 1})])
3296
+ jitters = 0
3297
+ elapsed = 0.0
3298
+ cur_x, cur_y = x, y
3299
+ hold_s = hold_ms / 1000.0
3300
+ while elapsed < hold_s:
3301
+ step = r.uniform(0.10, 0.20)
3302
+ # Don't overshoot the requested hold window.
3303
+ if elapsed + step > hold_s:
3304
+ step = max(0.0, hold_s - elapsed)
3305
+ await asyncio.sleep(step)
3306
+ elapsed += step
3307
+ if elapsed >= hold_s:
3308
+ break
3309
+ # +/-1-2px tremor around the press point, button still held (buttons:1).
3310
+ cur_x = x + r.randint(-2, 2)
3311
+ cur_y = y + r.randint(-2, 2)
3312
+ await cdp_send(ws_url, [(982, "Input.dispatchMouseEvent", {
3313
+ "type": "mouseMoved", "x": cur_x, "y": cur_y,
3314
+ "button": "left", "buttons": 1, "modifiers": 0})])
3315
+ jitters += 1
3316
+ # Release at (near) the press point.
3317
+ await cdp_send(ws_url, [(983, "Input.dispatchMouseEvent", {
3318
+ "type": "mouseReleased", "x": cur_x, "y": cur_y,
3319
+ "button": "left", "buttons": 0, "clickCount": 1})])
3320
+ return jitters
3321
+
3322
+
3323
+ async def _solve_press_and_hold(ws_url, target_sel=None):
3324
+ """Solve a PerimeterX/HUMAN "Press & Hold" challenge with a humanized gesture.
3325
+
3326
+ Finds the hold target (#px-captcha / [class*="px-captcha"] / press-and-hold
3327
+ text, or an explicit target_sel), then performs press->hold(with micro-tremor)
3328
+ ->release. Verifies via _detect_captcha; retries once with a different hold
3329
+ duration if the challenge is still present.
3330
+
3331
+ Returns: {"solved": bool, "method": "press_and_hold", "hold_ms": N,
3332
+ "attempts": N, ...}. Never raises.
3333
+ """
3334
+ last_hold = 0
3335
+ attempts = 0
3336
+ max_attempts = 2
3337
+ for attempts in range(1, max_attempts + 1):
3338
+ # (Re)locate the target each attempt -- the widget may re-render.
3339
+ try:
3340
+ find_expr = f"({PRESS_HOLD_FIND_JS})({json.dumps(target_sel) if target_sel else 'null'})"
3341
+ res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
3342
+ "expression": find_expr, "returnByValue": True})], timeout=8)
3343
+ raw = res.get(1, {}).get("result", {}).get("value")
3344
+ loc = json.loads(raw) if raw else {"found": False}
3345
+ except Exception as e:
3346
+ return {"solved": False, "method": "press_and_hold",
3347
+ "hold_ms": last_hold, "attempts": attempts,
3348
+ "error": f"locate_failed:{str(e)[:80]}"}
3349
+ if not loc.get("found"):
3350
+ return {"solved": False, "method": "press_and_hold",
3351
+ "hold_ms": last_hold, "attempts": attempts,
3352
+ "error": "target_not_found"}
3353
+
3354
+ hold_ms = _press_hold_duration_ms()
3355
+ last_hold = hold_ms
3356
+ try:
3357
+ jitters = await _press_hold_gesture(
3358
+ ws_url, int(loc["cx"]), int(loc["cy"]), hold_ms)
3359
+ except Exception as e:
3360
+ return {"solved": False, "method": "press_and_hold",
3361
+ "hold_ms": hold_ms, "attempts": attempts,
3362
+ "error": f"gesture_failed:{str(e)[:80]}"}
3363
+
3364
+ # Settle, then check whether the challenge cleared.
3365
+ import random as _r
3366
+ await asyncio.sleep((_r.Random().uniform(500, 1500)) / 1000.0)
3367
+ info = await _detect_captcha(ws_url)
3368
+ still_px = 'perimeterx' in (info.get('types') or [])
3369
+ if not still_px:
3370
+ return {"solved": True, "method": "press_and_hold",
3371
+ "hold_ms": hold_ms, "attempts": attempts,
3372
+ "jitter_moves": jitters}
3373
+ # else: loop and try once more with a fresh (re-drawn) hold duration.
3374
+
3375
+ return {"solved": False, "method": "press_and_hold",
3376
+ "hold_ms": last_hold, "attempts": attempts,
3377
+ "error": "still_present_after_retry"}
3378
+
3379
+
2776
3380
  async def _humanize_type(ws_url, text):
2777
3381
  """Type text with Gaussian inter-key and dwell delays via CDP key events."""
2778
3382
  import random as _r
@@ -3873,10 +4477,158 @@ async def cmd_debug(url=None):
3873
4477
  print(f"Debug complete: {url}")
3874
4478
 
3875
4479
 
3876
- async def cmd_close():
3877
- ws, page = get_page_ws()
3878
- r = await cdp_send(ws, [(1, "Page.close", {})])
3879
- print("Tab closed")
4480
+ # ─── Owned-Tab Tracking ───
4481
+ # cdpilot records every target_id it opens so that `close` can distinguish its
4482
+ # own tabs from tabs the user opened manually in the same isolated profile.
4483
+ # Stored as a JSON list in the project profile dir; survives across CLI
4484
+ # invocations (each `go`/`new-tab` is a separate process).
4485
+
4486
+ def _load_owned_tabs():
4487
+ """Return the set of target_ids cdpilot opened in this project profile."""
4488
+ try:
4489
+ with open(OWNED_TABS_FILE) as f:
4490
+ data = json.load(f)
4491
+ return set(data.get("owned", []))
4492
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
4493
+ return set()
4494
+
4495
+
4496
+ def _save_owned_tabs(owned):
4497
+ """Persist the owned target_id set."""
4498
+ try:
4499
+ os.makedirs(os.path.dirname(OWNED_TABS_FILE), exist_ok=True)
4500
+ with open(OWNED_TABS_FILE, "w") as f:
4501
+ json.dump({"owned": sorted(owned)}, f)
4502
+ except OSError:
4503
+ pass
4504
+
4505
+
4506
+ def _mark_owned_tab(target_id):
4507
+ """Record a target_id as cdpilot-owned. No-op on falsy input."""
4508
+ if not target_id:
4509
+ return
4510
+ owned = _load_owned_tabs()
4511
+ if target_id not in owned:
4512
+ owned.add(target_id)
4513
+ _save_owned_tabs(owned)
4514
+
4515
+
4516
+ def _is_chrome_internal_url(url):
4517
+ """True for blank/new-tab/internal pages that don't count as 'real' tabs."""
4518
+ if not url:
4519
+ return True
4520
+ u = url.strip().lower()
4521
+ return (u in ("about:blank", "about:newtab", "")
4522
+ or u.startswith("chrome://")
4523
+ or u.startswith("edge://")
4524
+ or u.startswith("chrome-extension://")
4525
+ or u.startswith("devtools://")
4526
+ or u.startswith("brave://")
4527
+ or u.startswith("vivaldi://"))
4528
+
4529
+
4530
+ async def _browser_close_graceful():
4531
+ """Ask the browser to shut down gracefully via the CDP Browser.close
4532
+ command (cleanest cross-platform path — flushes state, no orphaned procs).
4533
+ Returns True if the command was acknowledged. Falls back to a SIGTERM-based
4534
+ process stop (never kill -9) when the WebSocket path fails."""
4535
+ try:
4536
+ browser_ws = await _get_browser_ws()
4537
+ if browser_ws:
4538
+ await cdp_send(browser_ws, [(1, "Browser.close", {})], timeout=5)
4539
+ return True
4540
+ except SystemExit:
4541
+ raise
4542
+ except Exception:
4543
+ pass
4544
+ # Fallback: graceful OS-level termination (SIGTERM on POSIX, taskkill on
4545
+ # Windows). kill -9 is intentionally NOT used here — last resort only.
4546
+ return _stop_browser_on_port(CDP_PORT)
4547
+
4548
+
4549
+ async def cmd_close(force_browser=False, keep_browser=False):
4550
+ """Smart close: shut down cdpilot's own tabs, then close the whole browser
4551
+ if no user tabs remain.
4552
+
4553
+ Behavior:
4554
+ 1. Close every cdpilot-owned tab (CDP Target.closeTarget).
4555
+ 2. Inspect the remaining page targets:
4556
+ - 0 real tabs left (only about:blank / chrome:// / new-tab) ->
4557
+ close the browser application gracefully (Browser.close, SIGTERM
4558
+ fallback — never kill -9).
4559
+ - one or more user tabs remain -> leave the browser open; only the
4560
+ owned tabs were closed.
4561
+
4562
+ Flags:
4563
+ keep_browser — never close the browser, only the owned tabs.
4564
+ force_browser — close the browser even if user tabs remain.
4565
+ """
4566
+ if not cdp_get("/json/version"):
4567
+ print("No browser running.")
4568
+ return
4569
+
4570
+ tabs = get_tabs()
4571
+ pages = [t for t in tabs if t.get("type") == "page"]
4572
+ owned = _load_owned_tabs()
4573
+
4574
+ # Make sure the current session window counts as owned even if it predates
4575
+ # tracking (legacy session created before this feature shipped).
4576
+ session_target = _get_session_window_target_id()
4577
+ if session_target:
4578
+ owned.add(session_target)
4579
+
4580
+ browser_ws = await _get_browser_ws()
4581
+ closed = 0
4582
+ for p in pages:
4583
+ tid = p.get("id")
4584
+ if tid and tid in owned:
4585
+ try:
4586
+ await cdp_send(browser_ws, [(1, "Target.closeTarget",
4587
+ {"targetId": tid})], timeout=5)
4588
+ closed += 1
4589
+ except Exception:
4590
+ pass
4591
+
4592
+ # Owned set is consumed — clear tracking + the CWD session window pointer so
4593
+ # a later command starts clean.
4594
+ _save_owned_tabs(set())
4595
+ if session_target:
4596
+ sessions = _load_sessions()
4597
+ sid = _get_session_id()
4598
+ sessions.pop(sid, None)
4599
+ _save_sessions(sessions)
4600
+ cdp_cache_invalidate()
4601
+
4602
+ print(f"Closed {closed} cdpilot tab(s).")
4603
+
4604
+ # Re-read remaining targets after closing owned tabs.
4605
+ remaining = get_tabs()
4606
+ remaining_pages = [t for t in remaining if t.get("type") == "page"]
4607
+ user_pages = [p for p in remaining_pages
4608
+ if not _is_chrome_internal_url(p.get("url"))]
4609
+
4610
+ if keep_browser:
4611
+ if user_pages:
4612
+ print(f"Browser left open ({len(user_pages)} user tab(s)).")
4613
+ else:
4614
+ print("Browser left open (--keep).")
4615
+ return
4616
+
4617
+ if user_pages and not force_browser:
4618
+ print(f"Browser left open — {len(user_pages)} user tab(s) still present.")
4619
+ return
4620
+
4621
+ # No user tabs remain (or --force) -> close the browser gracefully.
4622
+ if await _browser_close_graceful():
4623
+ print(f"Browser closed gracefully (port {CDP_PORT}).")
4624
+ if PROJECT_ID:
4625
+ registry = _load_registry()
4626
+ if PROJECT_ID in registry:
4627
+ registry[PROJECT_ID]["status"] = "stopped"
4628
+ registry[PROJECT_ID]["pid"] = None
4629
+ _save_registry(registry)
4630
+ else:
4631
+ print("Browser close failed.", file=sys.stderr)
3880
4632
 
3881
4633
 
3882
4634
  def cmd_session():
@@ -4300,6 +5052,7 @@ async def cmd_new_tab(url='about:blank'):
4300
5052
  data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=safe_chars)}')
4301
5053
  cdp_cache_invalidate()
4302
5054
  if data:
5055
+ _mark_owned_tab(data.get("id"))
4303
5056
  print(f'New tab opened: {data.get("url", url)}')
4304
5057
  print(f' ID: {data.get("id", "?")}')
4305
5058
  else:
@@ -4870,6 +5623,14 @@ def cmd_stealth(state=None):
4870
5623
  os.makedirs(os.path.dirname(STEALTH_CONFIG_FILE), exist_ok=True)
4871
5624
  with open(STEALTH_CONFIG_FILE, 'w') as f:
4872
5625
  json.dump({'stealth': enabled}, f)
5626
+ # Backward-compat bridge to the three-tier model: keep mode.json coherent
5627
+ # with the legacy toggle so `stealth on` -> 'undetected' (full patch) and
5628
+ # `stealth off` -> 'regular'. Without this, an existing mode.json would
5629
+ # silently override the toggle (get_mode_config reads mode.json first).
5630
+ try:
5631
+ set_mode_config('undetected' if enabled else 'regular')
5632
+ except Exception:
5633
+ pass
4873
5634
  print(f'Stealth: {"on" if enabled else "off"}')
4874
5635
  print('Effect applies on next navigation (`cdpilot go <url>`).')
4875
5636
 
@@ -5128,18 +5889,25 @@ async def _assert_host(ws_url, expected_host):
5128
5889
 
5129
5890
 
5130
5891
  def get_adaptive_config():
5131
- """Return {'enabled': bool, 'stealth_hosts': [hostname,...]}."""
5892
+ """Return adaptive config with all learned-state keys carried through.
5893
+
5894
+ Keys: enabled, stealth_hosts, entropy_hosts, host_tiers. The latter two
5895
+ MUST be preserved here because callers write the whole dict back via
5896
+ _atomic_write_json — dropping a key would silently erase learned state.
5897
+ """
5132
5898
  if not os.path.exists(ADAPTIVE_CONFIG_FILE):
5133
- return {'enabled': False, 'stealth_hosts': []}
5899
+ return {'enabled': False, 'stealth_hosts': [], 'entropy_hosts': {}, 'host_tiers': {}}
5134
5900
  try:
5135
5901
  with open(ADAPTIVE_CONFIG_FILE) as f:
5136
5902
  data = json.load(f)
5137
5903
  return {
5138
5904
  'enabled': bool(data.get('enabled', False)),
5139
5905
  'stealth_hosts': list(data.get('stealth_hosts', [])),
5906
+ 'entropy_hosts': dict(data.get('entropy_hosts', {})),
5907
+ 'host_tiers': dict(data.get('host_tiers', {})),
5140
5908
  }
5141
5909
  except (OSError, ValueError):
5142
- return {'enabled': False, 'stealth_hosts': []}
5910
+ return {'enabled': False, 'stealth_hosts': [], 'entropy_hosts': {}, 'host_tiers': {}}
5143
5911
 
5144
5912
 
5145
5913
  def _adaptive_remember_host(hostname):
@@ -5151,6 +5919,48 @@ def _adaptive_remember_host(hostname):
5151
5919
  _atomic_write_json(ADAPTIVE_CONFIG_FILE, cfg)
5152
5920
 
5153
5921
 
5922
+ def _escalate_tier(tier):
5923
+ """Return the next-stronger tier. undetected is the ceiling."""
5924
+ try:
5925
+ i = MODE_TIERS.index(tier)
5926
+ except ValueError:
5927
+ i = 0
5928
+ return MODE_TIERS[min(i + 1, len(MODE_TIERS) - 1)]
5929
+
5930
+
5931
+ def _adaptive_remember_host_tier(hostname, tier):
5932
+ """Record the tier a host needs in adaptive.json under 'host_tiers'.
5933
+
5934
+ Monotonic: only ever ratchets UP (regular -> stealth -> undetected),
5935
+ never down — mirrors the never-auto-demote policy of the stealth_hosts
5936
+ list so a single false-negative CAPTCHA detection can't downgrade a host.
5937
+ """
5938
+ if tier not in MODE_TIERS:
5939
+ return
5940
+ cfg = get_adaptive_config()
5941
+ host_tiers = cfg.get('host_tiers', {})
5942
+ prev = host_tiers.get(hostname)
5943
+ if prev in MODE_TIERS and MODE_TIERS.index(prev) >= MODE_TIERS.index(tier):
5944
+ return # already at or above this tier, skip write
5945
+ host_tiers[hostname] = tier
5946
+ cfg['host_tiers'] = host_tiers
5947
+ _atomic_write_json(ADAPTIVE_CONFIG_FILE, cfg)
5948
+
5949
+
5950
+ def _adaptive_host_tier(url):
5951
+ """Return the learned tier for a URL's host, or None if none/disabled."""
5952
+ cfg = get_adaptive_config()
5953
+ if not cfg.get('enabled'):
5954
+ return None
5955
+ try:
5956
+ from urllib.parse import urlparse
5957
+ host = (urlparse(url).hostname or '').lower()
5958
+ except Exception:
5959
+ return None
5960
+ tier = cfg.get('host_tiers', {}).get(host)
5961
+ return tier if tier in MODE_TIERS else None
5962
+
5963
+
5154
5964
  def _adaptive_host_requires_stealth(url):
5155
5965
  """True if the URL's hostname is in the adaptive stealth list."""
5156
5966
  cfg = get_adaptive_config()
@@ -5281,35 +6091,169 @@ async def _detect_captcha(ws_url):
5281
6091
  return {"detected": False, "error": str(e)[:120]}
5282
6092
 
5283
6093
 
5284
- async def cmd_captcha_check():
5285
- """One-shot CAPTCHA detection on the active page. Prints JSON.
6094
+ # ─── Progressive-Resilience Escalation Ladder ───
6095
+ # Friction levels ordered low -> high. soft_captcha is detected by the existing
6096
+ # _detect_captcha (9 provider types); the other rungs come from
6097
+ # FRICTION_DETECT_JS. _detect_friction merges both and reports the highest.
6098
+ FRICTION_LEVELS = (
6099
+ 'none', 'rate_limited', 'soft_captcha', 'login_wall', 'otp_sms', 'hard_block',
6100
+ )
5286
6101
 
5287
- Exit codes: 0 = no CAPTCHA, 3 = CAPTCHA detected, 1 = error.
5288
- Useful in scripts: `cdpilot captcha-check && do-stuff`.
5289
- """
5290
- ws, _ = get_page_ws()
5291
- info = await _detect_captcha(ws)
5292
- print(json.dumps(info, ensure_ascii=False))
5293
- if info.get("error") and not info.get("detected"):
5294
- sys.exit(1)
5295
- sys.exit(3 if info.get("detected") else 0)
5296
6102
 
6103
+ def _friction_backoff_enabled():
6104
+ """Whether cmd_go should auto-backoff on rate_limited. Default on."""
6105
+ return os.environ.get('CDPILOT_FRICTION_BACKOFF', 'on').lower() not in ('off', '0', 'false', 'no')
5297
6106
 
5298
- async def cmd_captcha_wait(timeout_arg=None):
5299
- """Detect CAPTCHA and wait for the user to solve it.
5300
6107
 
5301
- Behavior:
5302
- - Interactive (stdin is a TTY): print warning, block on Enter.
5303
- - Non-interactive (pipe / MCP): poll every 2s until the CAPTCHA
5304
- disappears or timeout expires; emit one JSON line per poll on
5305
- state change so callers can stream progress.
6108
+ def _friction_max_retry():
6109
+ """Max rate-limit backoff retries in cmd_go. Default 2, clamped 0..5."""
6110
+ try:
6111
+ n = int(os.environ.get('CDPILOT_FRICTION_MAX_RETRY', '2'))
6112
+ except (ValueError, TypeError):
6113
+ n = 2
6114
+ return max(0, min(n, 5))
5306
6115
 
5307
- Args:
5308
- timeout_arg: int seconds (default 300, max 1800). Passed as string from CLI.
5309
6116
 
5310
- Exit codes: 0 = solved (or none detected), 2 = timeout, 1 = error.
6117
+ def _friction_backoff_seconds(attempt):
6118
+ """Exponential backoff with jitter: 2^attempt seconds, capped at 60s.
6119
+
6120
+ attempt is 0-based (first retry -> 2s base). Adds up to 25% jitter so
6121
+ concurrent workers don't synchronize their retries.
5311
6122
  """
5312
- try:
6123
+ import random as _r
6124
+ base = min(2 ** (attempt + 1), 60)
6125
+ jitter = _r.uniform(0, base * 0.25)
6126
+ return round(min(base + jitter, 60), 2)
6127
+
6128
+
6129
+ def _friction_action(level):
6130
+ """Map a friction level to the response policy cmd_go should apply.
6131
+
6132
+ ETHICS/SAFETY: login_wall, otp_sms and hard_block are NEVER bypassed
6133
+ autonomously — they hand off to the human. Only rate_limited is auto-handled
6134
+ (backoff + retry); soft_captcha defers to the existing captcha flow.
6135
+
6136
+ Returns a dict: {action, level, autonomous: bool, message}.
6137
+ """
6138
+ if level == 'rate_limited':
6139
+ return {
6140
+ 'action': 'backoff', 'level': level, 'autonomous': True,
6141
+ 'message': 'Rate limited — exponential backoff then retry.',
6142
+ }
6143
+ if level == 'soft_captcha':
6144
+ return {
6145
+ 'action': 'captcha', 'level': level, 'autonomous': True,
6146
+ 'message': 'CAPTCHA — defer to detect/wait/solver flow.',
6147
+ }
6148
+ if level == 'login_wall':
6149
+ return {
6150
+ 'action': 'human_login_required', 'level': level, 'autonomous': False,
6151
+ 'message': '🔐 Login gerekli — kullanıcı girişi bekleniyor (otomatik giriş YOK).',
6152
+ }
6153
+ if level == 'otp_sms':
6154
+ return {
6155
+ 'action': 'human_otp_required', 'level': level, 'autonomous': False,
6156
+ 'message': '📱 SMS/OTP doğrulama gerekli — kullanıcı çözmeli (otomatik çözüm YOK).',
6157
+ }
6158
+ if level == 'hard_block':
6159
+ return {
6160
+ 'action': 'hard_blocked', 'level': level, 'autonomous': False,
6161
+ 'message': '🚫 Sert engelleme — geri çekil ve bekle.', 'backoff_suggested': True,
6162
+ }
6163
+ return {'action': 'proceed', 'level': 'none', 'autonomous': True, 'message': 'No friction.'}
6164
+
6165
+
6166
+ async def _detect_friction(ws_url):
6167
+ """Detect the highest anti-bot friction rung on the active page.
6168
+
6169
+ Runs FRICTION_DETECT_JS (rate-limit / login-wall / OTP / hard-block) and
6170
+ merges in the existing _detect_captcha result (soft_captcha). Returns the
6171
+ HIGHEST rung present per FRICTION_LEVELS ordering.
6172
+
6173
+ Returns: {"level": str, "signals": [...], "detail": str, "captcha"?: {...}}
6174
+ Never raises — failures degrade to {"level": "none", ...}.
6175
+ """
6176
+ friction = {"level": "none", "signals": [], "detail": ""}
6177
+ try:
6178
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
6179
+ "expression": FRICTION_DETECT_JS,
6180
+ "returnByValue": True,
6181
+ })], timeout=5)
6182
+ raw = r.get(1, {}).get("result", {}).get("value")
6183
+ if raw:
6184
+ try:
6185
+ parsed = json.loads(raw)
6186
+ if isinstance(parsed, dict) and parsed.get("level") in FRICTION_LEVELS:
6187
+ friction = parsed
6188
+ except (ValueError, TypeError):
6189
+ pass
6190
+ except Exception as e:
6191
+ friction = {"level": "none", "signals": [], "detail": "", "error": str(e)[:120]}
6192
+
6193
+ # Merge soft_captcha from the dedicated captcha detector.
6194
+ try:
6195
+ cap = await _detect_captcha(ws_url)
6196
+ except Exception:
6197
+ cap = {"detected": False}
6198
+ if cap.get("detected"):
6199
+ cap_level = 'soft_captcha'
6200
+ # Keep whichever rung is higher in the ladder.
6201
+ cur_idx = FRICTION_LEVELS.index(friction.get('level', 'none')) if friction.get('level') in FRICTION_LEVELS else 0
6202
+ cap_idx = FRICTION_LEVELS.index(cap_level)
6203
+ if cap_idx > cur_idx:
6204
+ sigs = list(friction.get('signals', []))
6205
+ sigs.append('captcha:' + (",".join(cap.get('types', [])) or 'unknown'))
6206
+ friction = {
6207
+ 'level': cap_level, 'signals': sigs,
6208
+ 'detail': 'CAPTCHA challenge present (' + (",".join(cap.get('types', [])) or 'unknown') + ').',
6209
+ }
6210
+ friction['captcha'] = {'types': cap.get('types', []), 'detected': True}
6211
+ return friction
6212
+
6213
+
6214
+ async def cmd_friction():
6215
+ """One-shot friction-level diagnostic on the active page. Prints JSON.
6216
+
6217
+ Reports the highest escalation rung (none/rate_limited/soft_captcha/
6218
+ login_wall/otp_sms/hard_block) plus the recommended response policy.
6219
+ Read-only — never attempts to bypass anything.
6220
+ """
6221
+ ws, _ = get_page_ws()
6222
+ info = await _detect_friction(ws)
6223
+ action = _friction_action(info.get('level', 'none'))
6224
+ info['recommended'] = action
6225
+ print(json.dumps(info, ensure_ascii=False))
6226
+
6227
+
6228
+ async def cmd_captcha_check():
6229
+ """One-shot CAPTCHA detection on the active page. Prints JSON.
6230
+
6231
+ Exit codes: 0 = no CAPTCHA, 3 = CAPTCHA detected, 1 = error.
6232
+ Useful in scripts: `cdpilot captcha-check && do-stuff`.
6233
+ """
6234
+ ws, _ = get_page_ws()
6235
+ info = await _detect_captcha(ws)
6236
+ print(json.dumps(info, ensure_ascii=False))
6237
+ if info.get("error") and not info.get("detected"):
6238
+ sys.exit(1)
6239
+ sys.exit(3 if info.get("detected") else 0)
6240
+
6241
+
6242
+ async def cmd_captcha_wait(timeout_arg=None):
6243
+ """Detect CAPTCHA and wait for the user to solve it.
6244
+
6245
+ Behavior:
6246
+ - Interactive (stdin is a TTY): print warning, block on Enter.
6247
+ - Non-interactive (pipe / MCP): poll every 2s until the CAPTCHA
6248
+ disappears or timeout expires; emit one JSON line per poll on
6249
+ state change so callers can stream progress.
6250
+
6251
+ Args:
6252
+ timeout_arg: int seconds (default 300, max 1800). Passed as string from CLI.
6253
+
6254
+ Exit codes: 0 = solved (or none detected), 2 = timeout, 1 = error.
6255
+ """
6256
+ try:
5313
6257
  timeout = int(timeout_arg) if timeout_arg else 300
5314
6258
  except (ValueError, TypeError):
5315
6259
  print(f"Invalid timeout: {timeout_arg}", file=sys.stderr)
@@ -6089,6 +7033,387 @@ async def cmd_captcha_dispatch(args: list) -> None:
6089
7033
  sys.exit(1)
6090
7034
 
6091
7035
 
7036
+ # ─── Amazon classic image CAPTCHA (local OCR + BYOK image solvers) ────────
7037
+ # amazon.com rate-limit page: "Enter the characters you see below". The image
7038
+ # is a 6-char distorted glyph string at a fixed URL pattern. The optional
7039
+ # `amazoncaptcha` PyPI lib (pure-Python + Pillow, MIT) OCRs it offline with
7040
+ # ~90% accuracy. It is NOT a mandatory dependency — imported lazily.
7041
+ # BYOK fallback: CapSolver / 2Captcha image-to-text endpoints (HTTP only).
7042
+
7043
+
7044
+ async def _detect_amazon_captcha(ws_url: str) -> dict:
7045
+ """Return {'detected': bool, 'img_src': str, 'input_id': str} for Amazon classic CAPTCHA.
7046
+
7047
+ Probes the active page for the #captchacharacters input + captcha image.
7048
+ Never raises.
7049
+ """
7050
+ info = await _detect_captcha(ws_url)
7051
+ if not info.get('detected'):
7052
+ return {'detected': False}
7053
+ for d in info.get('details', []):
7054
+ if d.get('type') == 'amazon-classic':
7055
+ return {
7056
+ 'detected': True,
7057
+ 'img_src': d.get('img_src', ''),
7058
+ 'input_id': d.get('input_id', 'captchacharacters'),
7059
+ }
7060
+ return {'detected': False}
7061
+
7062
+
7063
+ async def _fetch_image_bytes(ws_url: str, img_src: str) -> bytes | None:
7064
+ """Download the captcha image bytes. Uses urllib (image URLs are public CDN)."""
7065
+ if not img_src:
7066
+ return None
7067
+ try:
7068
+ return await _captcha_urlopen_async(img_src, timeout=15)
7069
+ except Exception:
7070
+ return None
7071
+
7072
+
7073
+ def _solve_amazon_local(img_bytes: bytes) -> str | None:
7074
+ """OCR Amazon classic CAPTCHA with the OPTIONAL amazoncaptcha library.
7075
+
7076
+ Returns the solved string, or None if the library is unavailable or fails.
7077
+ Raises ImportError-as-signal via returning None; caller prints install hint.
7078
+ """
7079
+ try:
7080
+ from amazoncaptcha import AmazonCaptcha # optional dependency
7081
+ except ImportError:
7082
+ return None
7083
+ try:
7084
+ import io as _io
7085
+ cap = AmazonCaptcha(_io.BytesIO(img_bytes))
7086
+ solution = cap.solve()
7087
+ if solution and solution.lower() != 'not solved':
7088
+ return solution
7089
+ except Exception:
7090
+ return None
7091
+ return None
7092
+
7093
+
7094
+ async def _solve_image_byok(img_bytes: bytes) -> dict:
7095
+ """Solve an image CAPTCHA via a BYOK provider (CapSolver / 2Captcha).
7096
+
7097
+ Reads CAPSOLVER_API_KEY then TWOCAPTCHA_API_KEY from the environment.
7098
+ Only stdlib HTTP — no new dependency. Returns {'token': text} or {'error': ...}.
7099
+ """
7100
+ import base64 as _b64
7101
+ b64 = _b64.b64encode(img_bytes).decode('ascii')
7102
+
7103
+ capsolver_key = os.environ.get('CAPSOLVER_API_KEY')
7104
+ if capsolver_key:
7105
+ try:
7106
+ payload = json.dumps({
7107
+ 'clientKey': capsolver_key,
7108
+ 'task': {'type': 'ImageToTextTask', 'body': b64},
7109
+ }).encode()
7110
+ raw = await _captcha_urlopen_async(
7111
+ 'https://api.capsolver.com/createTask', data=payload,
7112
+ headers={'Content-Type': 'application/json'}, timeout=60,
7113
+ )
7114
+ r = json.loads(raw)
7115
+ if r.get('errorId', 0) == 0:
7116
+ text = (r.get('solution') or {}).get('text', '')
7117
+ if text:
7118
+ return {'token': text, 'provider': 'capsolver'}
7119
+ return {'error': f"capsolver: {r.get('errorDescription', r)}"}
7120
+ except Exception as e:
7121
+ return {'error': f'capsolver: {e}'}
7122
+
7123
+ twocaptcha_key = os.environ.get('TWOCAPTCHA_API_KEY')
7124
+ if twocaptcha_key:
7125
+ import urllib.parse as _uparse
7126
+ try:
7127
+ body = _uparse.urlencode({
7128
+ 'key': twocaptcha_key, 'method': 'base64',
7129
+ 'body': b64, 'json': '1',
7130
+ }).encode()
7131
+ raw = await _captcha_urlopen_async('https://2captcha.com/in.php', data=body, timeout=30)
7132
+ resp = json.loads(raw)
7133
+ if resp.get('status') != 1:
7134
+ return {'error': f"2captcha submit: {resp.get('request', resp)}"}
7135
+ task_id = resp['request']
7136
+ poll = f"https://2captcha.com/res.php?key={twocaptcha_key}&action=get&id={task_id}&json=1"
7137
+ deadline = time.time() + CAPTCHA_SOLVE_TIMEOUT
7138
+ while time.time() < deadline:
7139
+ await asyncio.sleep(5)
7140
+ r2 = json.loads(await _captcha_urlopen_async(poll, timeout=15))
7141
+ if r2.get('status') == 1:
7142
+ return {'token': r2['request'], 'provider': '2captcha'}
7143
+ if r2.get('request') != 'CAPCHA_NOT_READY':
7144
+ return {'error': f"2captcha: {r2.get('request')}"}
7145
+ return {'error': '2captcha timeout'}
7146
+ except Exception as e:
7147
+ return {'error': f'2captcha: {e}'}
7148
+
7149
+ return {'error': 'no_byok_key'}
7150
+
7151
+
7152
+ async def _fill_amazon_solution(ws_url: str, input_id: str, solution: str) -> bool:
7153
+ """Type the solution into the Amazon CAPTCHA input and submit. Returns True on success."""
7154
+ safe_id = json.dumps(input_id)
7155
+ safe_val = json.dumps(solution)
7156
+ js = f"""
7157
+ (function() {{
7158
+ try {{
7159
+ var el = document.getElementById({safe_id}) || document.querySelector('#captchacharacters');
7160
+ if (!el) return false;
7161
+ var setter = Object.getOwnPropertyDescriptor(
7162
+ window.HTMLInputElement.prototype, 'value').set;
7163
+ setter.call(el, {safe_val});
7164
+ el.dispatchEvent(new Event('input', {{bubbles: true}}));
7165
+ el.dispatchEvent(new Event('change', {{bubbles: true}}));
7166
+ var form = el.form || el.closest('form');
7167
+ if (form) {{ form.submit(); return true; }}
7168
+ var btn = document.querySelector('button[type=submit], input[type=submit]');
7169
+ if (btn) {{ btn.click(); return true; }}
7170
+ return true;
7171
+ }} catch(e) {{ return false; }}
7172
+ }})()
7173
+ """
7174
+ try:
7175
+ r = await cdp_send(ws_url, [(1, 'Runtime.evaluate', {
7176
+ 'expression': js, 'returnByValue': True,
7177
+ })], timeout=5)
7178
+ return bool(r.get(1, {}).get('result', {}).get('value', False))
7179
+ except Exception:
7180
+ return False
7181
+
7182
+
7183
+ async def cmd_press_hold(selector=None):
7184
+ """Solve a PerimeterX/HUMAN "Press & Hold" challenge (standalone, opt-in).
7185
+
7186
+ Performs a humanized press->hold(with micro-tremor)->release gesture on the
7187
+ hold target. If no selector is given, auto-locates the px-captcha widget
7188
+ (#px-captcha / [class*="px-captcha"] / "press & hold" text). Prints JSON;
7189
+ exit 0 = solved, 1 = not solved / target not found.
7190
+
7191
+ Usage:
7192
+ cdpilot press-hold # auto-find the px-captcha target
7193
+ cdpilot press-hold "#px-captcha button"
7194
+ """
7195
+ if not cdp_get("/json/version"):
7196
+ print(json.dumps({'solved': False, 'error': 'no_browser',
7197
+ 'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
7198
+ sys.exit(1)
7199
+ ws, _ = get_page_ws()
7200
+ res = await _solve_press_and_hold(ws, target_sel=selector)
7201
+ print(json.dumps(res, ensure_ascii=False))
7202
+ sys.exit(0 if res.get('solved') else 1)
7203
+
7204
+
7205
+ async def cmd_captcha_solve(provider=None):
7206
+ """Solve the CAPTCHA on the current page (opt-in).
7207
+
7208
+ Providers:
7209
+ amazon-local (default) — OCR Amazon classic image CAPTCHA with the optional
7210
+ `amazoncaptcha` lib (pip install amazoncaptcha).
7211
+ capsolver / 2captcha — BYOK image-to-text via CAPSOLVER_API_KEY /
7212
+ TWOCAPTCHA_API_KEY env vars (stdlib HTTP only).
7213
+
7214
+ Detects Amazon classic CAPTCHA (#captchacharacters + captcha image), solves,
7215
+ fills the input and submits. Prints JSON; exit 0 = solved, 3 = no captcha,
7216
+ 1 = error / unsolvable.
7217
+ """
7218
+ provider = (provider or 'amazon-local').lower()
7219
+ ws, _ = get_page_ws()
7220
+
7221
+ # PerimeterX / HUMAN "Press & Hold" is a behavioural (not token) challenge:
7222
+ # there is no provider to call, the only solution is a real humanized
7223
+ # press->hold->release gesture. Route it automatically whenever detected,
7224
+ # regardless of the requested provider, before the Amazon/BYOK path.
7225
+ pre = await _detect_captcha(ws)
7226
+ if 'perimeterx' in (pre.get('types') or []):
7227
+ res = await _solve_press_and_hold(ws)
7228
+ print(json.dumps(res, ensure_ascii=False))
7229
+ sys.exit(0 if res.get('solved') else 1)
7230
+
7231
+ amz = await _detect_amazon_captcha(ws)
7232
+ if not amz.get('detected'):
7233
+ # Not an Amazon classic captcha — report what (if anything) was found.
7234
+ info = await _detect_captcha(ws)
7235
+ print(json.dumps({
7236
+ 'solved': False, 'status': 'no_amazon_captcha',
7237
+ 'detected_types': info.get('types', []),
7238
+ }, ensure_ascii=False))
7239
+ sys.exit(3 if not info.get('detected') else 1)
7240
+
7241
+ img_src = amz.get('img_src', '')
7242
+ input_id = amz.get('input_id', 'captchacharacters')
7243
+ img_bytes = await _fetch_image_bytes(ws, img_src)
7244
+ if not img_bytes:
7245
+ print(json.dumps({'solved': False, 'error': 'image_fetch_failed', 'img_src': img_src}, ensure_ascii=False))
7246
+ sys.exit(1)
7247
+
7248
+ solution = None
7249
+ used_provider = None
7250
+
7251
+ if provider in ('amazon-local', 'amazon', 'local'):
7252
+ solution = _solve_amazon_local(img_bytes)
7253
+ used_provider = 'amazon-local'
7254
+ if solution is None:
7255
+ # Distinguish "lib missing" from "lib failed".
7256
+ try:
7257
+ import amazoncaptcha # noqa: F401
7258
+ print(json.dumps({
7259
+ 'solved': False, 'status': 'ocr_failed',
7260
+ 'provider': 'amazon-local',
7261
+ 'hint': 'OCR returned no result; retry or use --provider capsolver',
7262
+ }, ensure_ascii=False))
7263
+ except ImportError:
7264
+ print(json.dumps({
7265
+ 'solved': False, 'status': 'amazoncaptcha_not_installed',
7266
+ 'hint': 'amazoncaptcha not installed. Run: pip install amazoncaptcha (optional)',
7267
+ }, ensure_ascii=False))
7268
+ sys.exit(1)
7269
+ elif provider in ('capsolver', '2captcha', 'twocaptcha'):
7270
+ res = await _solve_image_byok(img_bytes)
7271
+ if 'error' in res:
7272
+ if res['error'] == 'no_byok_key':
7273
+ print(json.dumps({
7274
+ 'solved': False, 'status': 'no_byok_key',
7275
+ 'hint': 'Set CAPSOLVER_API_KEY or TWOCAPTCHA_API_KEY in the environment',
7276
+ }, ensure_ascii=False))
7277
+ else:
7278
+ print(json.dumps({'solved': False, 'error': res['error']}, ensure_ascii=False))
7279
+ sys.exit(1)
7280
+ solution = res.get('token')
7281
+ used_provider = res.get('provider', provider)
7282
+ else:
7283
+ print(json.dumps({'solved': False, 'error': f'unknown_provider:{provider}'}, ensure_ascii=False))
7284
+ sys.exit(1)
7285
+
7286
+ if not solution:
7287
+ print(json.dumps({'solved': False, 'error': 'empty_solution', 'provider': used_provider}, ensure_ascii=False))
7288
+ sys.exit(1)
7289
+
7290
+ filled = await _fill_amazon_solution(ws, input_id, solution)
7291
+ print(json.dumps({
7292
+ 'solved': True, 'solution': solution, 'provider': used_provider,
7293
+ 'submitted': filled,
7294
+ }, ensure_ascii=False))
7295
+ sys.exit(0)
7296
+
7297
+
7298
+ # ─── Profile warm-up (reCAPTCHA v3 score aging) ──────────────────────────
7299
+ # New browser profiles get a -0.5 reCAPTCHA v3 trust penalty. Browsing a few
7300
+ # popular, safe sites ages the cookie jar / history so the profile reads as
7301
+ # "established", lifting the v3 score. Opt-in, uses the already-open session.
7302
+
7303
+ WARM_SAFE_SITES = [
7304
+ 'https://en.wikipedia.org/wiki/Web_browser',
7305
+ 'https://github.com/',
7306
+ 'https://stackoverflow.com/',
7307
+ 'https://news.ycombinator.com/',
7308
+ 'https://www.bbc.com/news',
7309
+ 'https://www.reddit.com/',
7310
+ 'https://duckduckgo.com/',
7311
+ 'https://www.wikipedia.org/',
7312
+ ]
7313
+
7314
+
7315
+ async def cmd_profile_warm(minutes=None, sites=None):
7316
+ """Warm up the current profile by browsing safe popular sites (opt-in).
7317
+
7318
+ Ages cookies/history to boost reCAPTCHA v3 score. Uses the already-open
7319
+ browser session (does not launch). Default ~2 minutes over a hardcoded
7320
+ safe site list with randomized dwell + light scrolling between visits.
7321
+
7322
+ Args:
7323
+ minutes: budget in minutes (default 2, max 30). String from CLI.
7324
+ sites: optional comma-separated override list of URLs.
7325
+ """
7326
+ import random as _r
7327
+ try:
7328
+ budget_min = float(minutes) if minutes else 2.0
7329
+ except (ValueError, TypeError):
7330
+ budget_min = 2.0
7331
+ budget_min = max(0.5, min(budget_min, 30.0))
7332
+
7333
+ site_list = WARM_SAFE_SITES
7334
+ if sites:
7335
+ if isinstance(sites, str):
7336
+ site_list = [s.strip() for s in sites.split(',') if s.strip()]
7337
+ else:
7338
+ site_list = list(sites)
7339
+ if not site_list:
7340
+ site_list = WARM_SAFE_SITES
7341
+
7342
+ if not cdp_get("/json/version"):
7343
+ print(json.dumps({'error': 'no_browser', 'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
7344
+ sys.exit(1)
7345
+
7346
+ ws, _ = get_page_ws()
7347
+ deadline = time.time() + budget_min * 60.0
7348
+ rnd = _r.Random()
7349
+ visited = 0
7350
+ total = len(site_list)
7351
+
7352
+ for idx, url in enumerate(site_list, 1):
7353
+ if time.time() >= deadline:
7354
+ break
7355
+ try:
7356
+ await navigate_collect(ws, url)
7357
+ visited += 1
7358
+ sys.stderr.write(f"🔥 Warming profile: visited {visited}/{total} sites... ({url})\n")
7359
+ sys.stderr.flush()
7360
+ except Exception as e:
7361
+ sys.stderr.write(f"🔥 Warm: skip {url} ({str(e)[:60]})\n")
7362
+ continue
7363
+
7364
+ # Light scroll to look engaged (reuses humanized scroll).
7365
+ try:
7366
+ await _humanize_scroll(ws, rnd.randint(400, 1200))
7367
+ except Exception:
7368
+ pass
7369
+
7370
+ # Random aging delay 5-15s, but never overrun the budget.
7371
+ remaining = deadline - time.time()
7372
+ if remaining <= 0:
7373
+ break
7374
+ await asyncio.sleep(min(rnd.uniform(5, 15), max(0.1, remaining)))
7375
+
7376
+ print(json.dumps({
7377
+ 'warmed': True, 'visited': visited, 'sites': total,
7378
+ 'budget_minutes': budget_min,
7379
+ }, ensure_ascii=False))
7380
+
7381
+
7382
+ async def cmd_profile_dispatch(args: list) -> None:
7383
+ """Top-level dispatcher for 'cdpilot profile <subcommand> [args...]'.
7384
+
7385
+ Subcommands:
7386
+ warm [--minutes N] [--sites url1,url2] Warm up profile (reCAPTCHA v3 aging).
7387
+ """
7388
+ if not args:
7389
+ print(
7390
+ "Usage: cdpilot profile <subcommand>\n"
7391
+ " warm [--minutes N] [--sites url1,url2,...] Age cookies/history to boost reCAPTCHA v3 score",
7392
+ file=sys.stderr,
7393
+ )
7394
+ sys.exit(1)
7395
+
7396
+ sub = args[0].lower()
7397
+ rest = args[1:]
7398
+
7399
+ def _get_flag(flag):
7400
+ for i, a in enumerate(rest):
7401
+ if a == flag and i + 1 < len(rest):
7402
+ return rest[i + 1]
7403
+ if a.startswith(flag + '='):
7404
+ return a.split('=', 1)[1]
7405
+ return None
7406
+
7407
+ if sub == 'warm':
7408
+ await cmd_profile_warm(
7409
+ minutes=_get_flag('--minutes') or _get_flag('-m'),
7410
+ sites=_get_flag('--sites'),
7411
+ )
7412
+ else:
7413
+ print(f"Unknown profile subcommand: {sub}", file=sys.stderr)
7414
+ sys.exit(1)
7415
+
7416
+
6092
7417
  # ─── End Captcha Solver Plugin ───────────────────────────────────────────
6093
7418
 
6094
7419
 
@@ -6989,16 +8314,48 @@ async def cmd_smart_click(text):
6989
8314
  cdpilot smart-click "Learn more"
6990
8315
  """
6991
8316
  ws_url, _ = get_page_ws()
8317
+ # Snapshot page targets before the click so a new tab opened by the click
8318
+ # (target=_blank, window.open) can be marked cdpilot-owned afterwards.
8319
+ _pre_click_targets = {t.get("id") for t in (cdp_get("/json") or [])
8320
+ if t.get("type") == "page"}
8321
+ # Locale-aware lowercase so Turkish/German chars round-trip safely.
8322
+ # `toLocaleLowerCase()` (no arg) honors the browser's BCP-47 locale, which
8323
+ # matches what the user sees in the DOM. Python's str.lower() handles
8324
+ # ASCII fine for the *search* term that we marshal across; the in-page
8325
+ # comparison uses the same locale-aware function on both sides.
6992
8326
  safe_text = json.dumps(text.lower())
6993
8327
  js = f"""
6994
8328
  (function() {{
6995
- var search = {safe_text};
8329
+ // Walk into shadow roots so Salesforce Lightning / Polymer / any custom
8330
+ // element with attachShadow({{mode:'open'}}) becomes searchable.
8331
+ // Closed shadow roots are inaccessible by design — nothing we can do
8332
+ // about those without a DevTools-only API.
8333
+ function deepQuerySelectorAll(root, selector) {{
8334
+ var found = Array.from(root.querySelectorAll(selector));
8335
+ Array.from(root.querySelectorAll('*')).forEach(function(el) {{
8336
+ if (el.shadowRoot) {{
8337
+ found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
8338
+ }}
8339
+ }});
8340
+ return found;
8341
+ }}
8342
+
8343
+ function lc(s) {{
8344
+ // Locale-aware lowercase — `toLowerCase()` mishandles Turkish (İ → i̇
8345
+ // combining sequence in some engines) and stays case-sensitive on
8346
+ // ß in older runtimes. `toLocaleLowerCase()` matches the user's
8347
+ // expected mental model: "İletişim" → "iletişim".
8348
+ return (s == null ? '' : (s + '')).toLocaleLowerCase();
8349
+ }}
8350
+
8351
+ var search = lc({safe_text}).trim();
6996
8352
  var candidates = [];
8353
+ var disabledCount = 0;
6997
8354
 
6998
8355
  // Score: exact > startsWith > includes > partial
6999
8356
  function score(str) {{
7000
8357
  if (!str) return 0;
7001
- var s = str.toLowerCase().trim();
8358
+ var s = lc(str).trim();
7002
8359
  if (s === search) return 100;
7003
8360
  if (s.startsWith(search)) return 80;
7004
8361
  if (s.includes(search)) return 60;
@@ -7009,13 +8366,13 @@ async def cmd_smart_click(text):
7009
8366
  return 0;
7010
8367
  }}
7011
8368
 
7012
- var els = document.querySelectorAll(
8369
+ var els = deepQuerySelectorAll(document,
7013
8370
  'a, button, input[type=submit], input[type=button], ' +
7014
8371
  '[role=button], [role=link], [role=tab], [role=menuitem], ' +
7015
8372
  'summary, label, [onclick], [tabindex]'
7016
8373
  );
7017
8374
 
7018
- Array.from(els).forEach(function(el) {{
8375
+ els.forEach(function(el) {{
7019
8376
  var rect = el.getBoundingClientRect();
7020
8377
  if (rect.width === 0 && rect.height === 0) return;
7021
8378
  var style = window.getComputedStyle(el);
@@ -7034,10 +8391,23 @@ async def cmd_smart_click(text):
7034
8391
  var bestMatch = '';
7035
8392
  texts.forEach(function(t) {{
7036
8393
  var s = score(t);
7037
- if (s > bestScore) {{ bestScore = s; bestMatch = t.trim().substring(0, 60); }}
8394
+ if (s > bestScore) {{ bestScore = s; bestMatch = (t + '').trim().substring(0, 60); }}
7038
8395
  }});
7039
8396
 
7040
8397
  if (bestScore > 0) {{
8398
+ // Reject elements that look clickable but won't fire — `<button
8399
+ // disabled>`, `[aria-disabled=true]`, or a disabled ancestor
8400
+ // (fieldset[disabled] propagates to children). Without this the
8401
+ // command reports "Clicked" but nothing happens — silent fail.
8402
+ var isDisabled = (
8403
+ el.disabled === true ||
8404
+ el.getAttribute('aria-disabled') === 'true' ||
8405
+ (typeof el.closest === 'function' && el.closest('fieldset[disabled], [aria-disabled="true"]') !== null)
8406
+ );
8407
+ if (isDisabled) {{
8408
+ disabledCount++;
8409
+ return;
8410
+ }}
7041
8411
  candidates.push({{
7042
8412
  el: el,
7043
8413
  score: bestScore,
@@ -7049,7 +8419,13 @@ async def cmd_smart_click(text):
7049
8419
  }}
7050
8420
  }});
7051
8421
 
7052
- if (candidates.length === 0) return JSON.stringify({{found: false}});
8422
+ if (candidates.length === 0) {{
8423
+ return JSON.stringify({{
8424
+ found: false,
8425
+ allDisabled: disabledCount > 0,
8426
+ disabledCount: disabledCount
8427
+ }});
8428
+ }}
7053
8429
 
7054
8430
  candidates.sort(function(a, b) {{ return b.score - a.score; }});
7055
8431
  var best = candidates[0];
@@ -7078,10 +8454,26 @@ async def cmd_smart_click(text):
7078
8454
  return
7079
8455
 
7080
8456
  if not data.get("found"):
7081
- print(f'No element found matching: "{text}"', file=sys.stderr)
8457
+ if data.get("allDisabled"):
8458
+ # Distinguish "no match" from "match exists but disabled" — the
8459
+ # latter is almost always a timing bug in the caller (form
8460
+ # validation hasn't unlocked the submit button yet).
8461
+ print(f'Error: no enabled element matches "{text}" '
8462
+ f'({data.get("disabledCount", 0)} disabled match(es) skipped)', file=sys.stderr)
8463
+ else:
8464
+ print(f'No element found matching: "{text}"', file=sys.stderr)
7082
8465
  sys.exit(1)
7083
8466
 
7084
8467
  await _vfx_ripple(ws_url, data["x"], data["y"])
8468
+ # A click can spawn a new tab (target=_blank). Diff the target list and mark
8469
+ # any newcomer as cdpilot-owned so `close` will clean it up later.
8470
+ try:
8471
+ cdp_cache_invalidate()
8472
+ for t in (cdp_get("/json", no_cache=True) or []):
8473
+ if t.get("type") == "page" and t.get("id") not in _pre_click_targets:
8474
+ _mark_owned_tab(t.get("id"))
8475
+ except Exception:
8476
+ pass
7085
8477
  print(f'Clicked: {data["tag"].upper()} "{data["text"]}" (score:{data["score"]})')
7086
8478
  if data.get("alternatives"):
7087
8479
  print(f' Also found: {", ".join(data["alternatives"])}')
@@ -7287,7 +8679,9 @@ async def cmd_dismiss(repeat=None):
7287
8679
  async def cmd_smart_fill(text, value):
7288
8680
  """Fill input by label/placeholder text — no CSS selector needed.
7289
8681
 
7290
- Finds input by: associated label, placeholder, aria-label, name, id match.
8682
+ Finds input by: associated label, placeholder, aria-label, name, id match,
8683
+ aria-labelledby, closest [aria-label], and nearby (4-prev) labels for
8684
+ floating-label designs (Material UI, Ant Design, Chakra).
7291
8685
 
7292
8686
  Usage:
7293
8687
  cdpilot smart-fill "Email" "test@example.com"
@@ -7299,44 +8693,97 @@ async def cmd_smart_fill(text, value):
7299
8693
  safe_value = json.dumps(value)
7300
8694
  js = f"""
7301
8695
  (function() {{
7302
- var search = {safe_text};
8696
+ // Shadow DOM traversal — same helper as smart-click. Custom-element
8697
+ // form controls (e.g. <sf-input> in Salesforce Lightning) only
8698
+ // expose their <input> via shadow root.
8699
+ function deepQuerySelectorAll(root, selector) {{
8700
+ var found = Array.from(root.querySelectorAll(selector));
8701
+ Array.from(root.querySelectorAll('*')).forEach(function(el) {{
8702
+ if (el.shadowRoot) {{
8703
+ found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
8704
+ }}
8705
+ }});
8706
+ return found;
8707
+ }}
8708
+
8709
+ function lc(s) {{
8710
+ return (s == null ? '' : (s + '')).toLocaleLowerCase();
8711
+ }}
8712
+
8713
+ var search = lc({safe_text}).trim();
7303
8714
  var value = {safe_value};
7304
8715
  var candidates = [];
7305
8716
 
7306
8717
  function score(str) {{
7307
8718
  if (!str) return 0;
7308
- var s = str.toLowerCase().trim();
8719
+ var s = lc(str).trim();
7309
8720
  if (s === search) return 100;
7310
8721
  if (s.startsWith(search)) return 80;
7311
8722
  if (s.includes(search)) return 60;
7312
8723
  return 0;
7313
8724
  }}
7314
8725
 
7315
- var inputs = document.querySelectorAll('input, textarea, select, [contenteditable=true]');
7316
- Array.from(inputs).forEach(function(el) {{
8726
+ var inputs = deepQuerySelectorAll(document,
8727
+ 'input, textarea, select, [contenteditable=true]');
8728
+ inputs.forEach(function(el) {{
7317
8729
  var rect = el.getBoundingClientRect();
7318
8730
  if (rect.width === 0 && rect.height === 0) return;
7319
8731
 
7320
8732
  var scores = [];
7321
- // Check placeholder
8733
+ // 1. placeholder
7322
8734
  scores.push(score(el.getAttribute('placeholder') || ''));
7323
- // Check aria-label
8735
+ // 2. aria-label
7324
8736
  scores.push(score(el.getAttribute('aria-label') || ''));
7325
- // Check name/id
8737
+ // 3. name / id
7326
8738
  scores.push(score(el.name || ''));
7327
8739
  scores.push(score(el.id || ''));
7328
- // Check associated label
8740
+ // 4. label[for=id] (classic)
7329
8741
  if (el.id) {{
7330
8742
  var label = document.querySelector('label[for="' + el.id + '"]');
7331
8743
  if (label) scores.push(score(label.textContent || ''));
7332
8744
  }}
7333
- // Check parent label
8745
+ // 5. parent <label>
7334
8746
  var parentLabel = el.closest('label');
7335
8747
  if (parentLabel) scores.push(score(parentLabel.textContent || ''));
7336
- // Check preceding text node/label
8748
+ // 6. immediately-preceding sibling (legacy fallback)
7337
8749
  var prev = el.previousElementSibling;
7338
8750
  if (prev) scores.push(score(prev.textContent || ''));
7339
8751
 
8752
+ // 7. aria-labelledby — the input points at one or more IDs whose
8753
+ // textContent forms the accessible name. ARIA spec says space-
8754
+ // separated tokens, in order.
8755
+ var labelledby = el.getAttribute('aria-labelledby');
8756
+ if (labelledby) {{
8757
+ labelledby.split(/\\s+/).forEach(function(id) {{
8758
+ if (!id) return;
8759
+ var ref = document.getElementById(id);
8760
+ if (ref) scores.push(score(ref.textContent || ''));
8761
+ }});
8762
+ }}
8763
+ // 8. closest ancestor with [aria-label] — Material UI / Chakra wrap
8764
+ // the input in a labelled container instead of using <label>.
8765
+ if (typeof el.closest === 'function') {{
8766
+ var aria = el.closest('[aria-label]');
8767
+ if (aria && aria !== el) scores.push(score(aria.getAttribute('aria-label') || ''));
8768
+ }}
8769
+ // 9. nearby label — walk up to 4 previous siblings looking for a
8770
+ // <label> or label-like element. Floating-label designs render
8771
+ // the label as a separate sibling at the parent level.
8772
+ var node = el;
8773
+ for (var depth = 0; depth < 2 && node && node.parentElement; depth++) {{
8774
+ var sib = node.previousElementSibling;
8775
+ for (var i = 0; i < 4 && sib; i++) {{
8776
+ if (sib.tagName === 'LABEL' ||
8777
+ sib.matches && (sib.matches('label, [class*="label" i], [class*="Label"]'))) {{
8778
+ var t = (sib.textContent || '').trim();
8779
+ if (t && t.length < 80) scores.push(score(t));
8780
+ break;
8781
+ }}
8782
+ sib = sib.previousElementSibling;
8783
+ }}
8784
+ node = node.parentElement;
8785
+ }}
8786
+
7340
8787
  var bestScore = Math.max.apply(null, scores);
7341
8788
  if (bestScore > 0) {{
7342
8789
  candidates.push({{el: el, score: bestScore, tag: el.tagName.toLowerCase(), type: el.type || ''}});
@@ -7400,13 +8847,38 @@ async def cmd_smart_select(text, option_text):
7400
8847
  safe_option = json.dumps(option_text.lower())
7401
8848
  js = f"""
7402
8849
  (function() {{
7403
- var search = {safe_text};
7404
- var optSearch = {safe_option};
7405
- var selects = document.querySelectorAll('select');
8850
+ // Shadow DOM + locale-aware comparison — same conventions as smart-click
8851
+ // / smart-fill so all three commands behave consistently on Lightning,
8852
+ // Polymer, and Turkish/German content.
8853
+ function deepQuerySelectorAll(root, selector) {{
8854
+ var found = Array.from(root.querySelectorAll(selector));
8855
+ Array.from(root.querySelectorAll('*')).forEach(function(el) {{
8856
+ if (el.shadowRoot) {{
8857
+ found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
8858
+ }}
8859
+ }});
8860
+ return found;
8861
+ }}
8862
+
8863
+ function lc(s) {{
8864
+ return (s == null ? '' : (s + '')).toLocaleLowerCase();
8865
+ }}
8866
+
8867
+ var search = lc({safe_text}).trim();
8868
+ var optSearch = lc({safe_option}).trim();
8869
+ var selects = deepQuerySelectorAll(document, 'select');
7406
8870
  var best = null;
7407
8871
  var bestScore = 0;
7408
-
7409
- Array.from(selects).forEach(function(sel) {{
8872
+ var disabledCount = 0;
8873
+
8874
+ selects.forEach(function(sel) {{
8875
+ // Skip disabled selects — same rationale as smart-click: silently
8876
+ // setting .value on a disabled <select> is a no-op and confusing.
8877
+ var isDisabled = (
8878
+ sel.disabled === true ||
8879
+ sel.getAttribute('aria-disabled') === 'true' ||
8880
+ (typeof sel.closest === 'function' && sel.closest('fieldset[disabled], [aria-disabled="true"]') !== null)
8881
+ );
7410
8882
  var texts = [
7411
8883
  sel.getAttribute('aria-label') || '',
7412
8884
  sel.name || '', sel.id || ''
@@ -7418,21 +8890,30 @@ async def cmd_smart_select(text, option_text):
7418
8890
  var parent = sel.closest('label');
7419
8891
  if (parent) texts.push(parent.textContent || '');
7420
8892
 
8893
+ var matched = false;
7421
8894
  texts.forEach(function(t) {{
7422
- var s = t.toLowerCase().trim();
8895
+ var s = lc(t).trim();
7423
8896
  var sc = s === search ? 100 : s.includes(search) ? 60 : 0;
7424
- if (sc > bestScore) {{ bestScore = sc; best = sel; }}
8897
+ if (sc > 0) matched = true;
8898
+ if (sc > bestScore && !isDisabled) {{ bestScore = sc; best = sel; }}
7425
8899
  }});
8900
+ if (matched && isDisabled) disabledCount++;
7426
8901
  }});
7427
8902
 
7428
- if (!best) return JSON.stringify({{found: false}});
8903
+ if (!best) {{
8904
+ return JSON.stringify({{
8905
+ found: false,
8906
+ allDisabled: disabledCount > 0,
8907
+ disabledCount: disabledCount
8908
+ }});
8909
+ }}
7429
8910
 
7430
8911
  // Find matching option
7431
8912
  var options = Array.from(best.options);
7432
8913
  var match = options.find(function(o) {{
7433
- return o.text.toLowerCase().trim() === optSearch;
8914
+ return lc(o.text).trim() === optSearch;
7434
8915
  }}) || options.find(function(o) {{
7435
- return o.text.toLowerCase().includes(optSearch);
8916
+ return lc(o.text).includes(optSearch);
7436
8917
  }});
7437
8918
 
7438
8919
  if (!match) return JSON.stringify({{found: true, optionFound: false, available: options.map(function(o) {{ return o.text; }}).slice(0, 10)}});
@@ -7451,7 +8932,11 @@ async def cmd_smart_select(text, option_text):
7451
8932
  return
7452
8933
 
7453
8934
  if not data.get("found"):
7454
- print(f'No select found matching: "{text}"', file=sys.stderr)
8935
+ if data.get("allDisabled"):
8936
+ print(f'Error: no enabled select matches "{text}" '
8937
+ f'({data.get("disabledCount", 0)} disabled match(es) skipped)', file=sys.stderr)
8938
+ else:
8939
+ print(f'No select found matching: "{text}"', file=sys.stderr)
7455
8940
  sys.exit(1)
7456
8941
  if not data.get("optionFound"):
7457
8942
  print(f'Option "{option_text}" not found. Available: {", ".join(data.get("available", []))}', file=sys.stderr)
@@ -9567,8 +11052,8 @@ class MCPServer:
9567
11052
  "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element"}, "value": {"type": "string", "description": "Value to set in the input field"}}, "required": ["selector", "value"]}},
9568
11053
  {"name": "browser_launch", "description": "Launch an isolated browser instance with Chrome DevTools Protocol enabled. Uses existing Brave/Chrome/Chromium installation — no browser download needed. Creates an isolated profile directory so your personal browser data is never touched.",
9569
11054
  "inputSchema": {"type": "object", "properties": {}}},
9570
- {"name": "browser_close", "description": "Close the currently active browser tab. Use this to clean up after automation tasks or to close unwanted popups/tabs that were opened during navigation.",
9571
- "inputSchema": {"type": "object", "properties": {}}},
11055
+ {"name": "browser_close", "description": "Smart close: shut down every tab cdpilot opened during this automation, then close the whole browser application gracefully ONLY if no user-opened tabs remain. If the user has their own tabs open in the same browser, those are left untouched and the browser stays open. Use this to clean up after automation. Set force=true to close the browser even when user tabs remain; set keep_browser=true to only close cdpilot's tabs and never quit the browser.",
11056
+ "inputSchema": {"type": "object", "properties": {"force": {"type": "boolean", "description": "Close the browser application even if user-opened tabs remain.", "default": False}, "keep_browser": {"type": "boolean", "description": "Only close cdpilot's own tabs; never quit the browser.", "default": False}}}},
9572
11057
  {"name": "browser_extract", "description": "Extract structured data from elements matching a CSS selector. No LLM needed — pure DOM extraction. Returns text (default), JSON (with tag, text, attrs, href, src), specific attributes, or clean list. Use for scraping tables, lists, links, form values. Limit: 100 elements for JSON, 200 for text.",
9573
11058
  "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to match elements (e.g. 'table tr', '.product', 'a')"}, "format": {"type": "string", "enum": ["text", "json", "list"], "description": "Output format: 'text' (one per line), 'json' (full structure with attrs), 'list' (numbered)", "default": "text"}}, "required": ["selector"]}},
9574
11059
  {"name": "browser_observe", "description": "List all interactive elements on the current page with their available actions (CLICK, FILL, NAVIGATE, TOGGLE, SELECT, SUBMIT, UPLOAD). Like Stagehand observe() but deterministic — no LLM needed. Shows what you CAN DO on the page. Use this to understand page structure before acting.",
@@ -9577,6 +11062,11 @@ class MCPServer:
9577
11062
  "inputSchema": {"type": "object", "properties": {"text": {"type": "string", "description": "Visible text of the element to click (e.g. 'Login', 'Submit Order', 'Learn more')"}}, "required": ["text"]}},
9578
11063
  {"name": "browser_dismiss", "description": "Find and click the strongest 'dismiss / continue without account' button on the page. Built-in pattern library covers English + Turkish dismissive phrases ('Stay signed out', 'No thanks', 'Skip', 'Continue without', etc.) and explicitly excludes destructive lookalikes (Delete account, Sign out, Subscribe). Designed for LLM chat sites that gate queries behind a sign-up modal. Pass 'aggressive' or an integer N (max 10) to handle chained modals.",
9579
11064
  "inputSchema": {"type": "object", "properties": {"repeat": {"type": "string", "description": "Optional. Number of dismiss attempts (1-10) or 'aggressive' (up to 5 chained). Default: 1.", "default": "1"}}}},
11065
+ {"name": "browser_captcha_solve", "description": "Solve the CAPTCHA on the current page (opt-in). Detects and solves Amazon classic image CAPTCHA (the \"Type the characters you see\" rate-limit page) by OCRing the image, filling the input, and submitting. Default provider 'amazon-local' uses the optional amazoncaptcha library (pip install amazoncaptcha) for offline OCR. BYOK providers 'capsolver' / '2captcha' use image-to-text APIs via CAPSOLVER_API_KEY / TWOCAPTCHA_API_KEY env vars. Returns JSON with solved status. For token-based CAPTCHAs (reCAPTCHA, hCaptcha, Turnstile) use the captcha config/auto CLI instead.",
11066
+ "inputSchema": {"type": "object", "properties": {"provider": {"type": "string", "enum": ["amazon-local", "capsolver", "2captcha"], "description": "Solver provider. Default: amazon-local (offline OCR via optional amazoncaptcha lib).", "default": "amazon-local"}}}},
11067
+ {"name": "browser_press_hold", "description": "Solve a PerimeterX/HUMAN \"Press & Hold\" challenge on the current page (opt-in). PerimeterX is a BEHAVIOURAL challenge -- the user must press AND HOLD a button for several seconds while the detector measures hold duration, natural hand-tremor, and release timing. It is NOT token-based, so there is no provider to call: the only solution is a real humanized press->hold->release gesture, which this tool emits via CDP Input events (Gaussian-randomized ~3-7s hold with +/-1-2px micro-jitter while the button is held). Auto-locates the #px-captcha widget (or pass an explicit selector). Returns JSON with solved status, hold_ms, and attempts. Use this when browser_friction/captcha-check reports a 'perimeterx' type.", "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "Optional CSS selector for the hold target. If omitted, auto-finds the px-captcha button."}}}},
11068
+ {"name": "browser_friction", "description": "Detect the highest anti-bot 'friction' rung on the current page and return the recommended response policy as JSON. Real sites stack defenses incrementally (rate-limit -> CAPTCHA -> login-wall -> SMS/OTP -> hard-block); this reports which rung is currently active. Levels (low->high): none, rate_limited, soft_captcha, login_wall, otp_sms, hard_block. Bilingual (English + Turkish) DOM heuristics. Read-only — never bypasses anything. login_wall/otp_sms/hard_block are flagged for HUMAN handoff (not autonomously solved) for safety/ethics; rate_limited recommends exponential backoff; soft_captcha defers to the captcha tools. Use this for diagnosis before deciding how to proceed on a gated site.",
11069
+ "inputSchema": {"type": "object", "properties": {}}},
9580
11070
  {"name": "browser_smart_fill", "description": "Fill an input by its label or placeholder text — no CSS selector needed. Finds input by associated label, placeholder, aria-label, name, or id. React-compatible value setting. Use when you know WHAT field to fill but not the exact selector.",
9581
11071
  "inputSchema": {"type": "object", "properties": {"label": {"type": "string", "description": "Label or placeholder text of the input (e.g. 'Email', 'Password', 'Search')"}, "value": {"type": "string", "description": "Value to fill in the input"}}, "required": ["label", "value"]}},
9582
11072
  {"name": "browser_smart_select", "description": "Select a dropdown option by label and option text — no CSS selector needed. Finds the select element by label/name, then selects the matching option. Use for dropdown interactions without knowing selectors.",
@@ -9607,6 +11097,32 @@ class MCPServer:
9607
11097
  "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element expected to be hidden"}}, "required": ["selector"]}},
9608
11098
  {"name": "browser_screenshot_diff", "description": "Compare two screenshot PNG files byte-by-byte. Returns MATCH if files are identical or DIFF with file sizes if different. Use this for visual regression testing — take a baseline screenshot, perform actions, take another screenshot, then compare.",
9609
11099
  "inputSchema": {"type": "object", "properties": {"path1": {"type": "string", "description": "Absolute path to the first (baseline) screenshot PNG"}, "path2": {"type": "string", "description": "Absolute path to the second (current) screenshot PNG"}}, "required": ["path1", "path2"]}},
11100
+ {"name": "browser_watch_start", "description": "Begin a continuous JPEG screencast of the active page to disk. CDP's Page.startScreencast streams frames at 10-30fps so the model can actually SEE animation, mouse cursors, and short visual events that single screenshots miss. Spawns a background daemon that writes JPEGs into a per-project ring buffer (default 5 min retention, 100MB cap). Use this to capture video playback, animated demos, or any time-based UI for later multimodal analysis.",
11101
+ "inputSchema": {"type": "object", "properties": {
11102
+ "url": {"type": "string", "description": "URL or file:// path to load before recording. Pass '-' or omit to attach to the currently-loaded page."},
11103
+ "fps": {"type": "integer", "description": "Frames per second (1-30). Higher = more detail but bigger ring buffer.", "default": 10},
11104
+ "quality": {"type": "integer", "description": "JPEG quality 1-100.", "default": 70},
11105
+ "max_width": {"type": "integer", "description": "Maximum frame width in pixels (shrinks proportionally).", "default": 1280},
11106
+ "retention_s": {"type": "integer", "description": "Seconds to keep frames before eviction.", "default": 300},
11107
+ "disk_cap_mb": {"type": "integer", "description": "Maximum total frame size in MB before oldest-first eviction.", "default": 100},
11108
+ "seek": {"type": "string", "description": "Optional MM:SS or seconds to seek the page's first <video> element to."}
11109
+ }}},
11110
+ {"name": "browser_watch_stop", "description": "Stop the background screencast daemon and (by default) delete the captured frames. Use after you've finished querying so you don't leak disk. Pass keep_frames=true to keep the ring buffer for later inspection.",
11111
+ "inputSchema": {"type": "object", "properties": {
11112
+ "keep_frames": {"type": "boolean", "description": "Keep the captured frames on disk instead of deleting them.", "default": False}
11113
+ }}},
11114
+ {"name": "browser_watch_query", "description": "Return the file paths of JPEG frames captured around a target time window. Frames live on local disk so you can read them and feed them to a multimodal API (vision call) to actually understand what happened. Choose ONE of: at_window (centered on a video time), last (relative to the newest frame), since_last (everything new since the previous query). Returns at most `max` frames (default 16) — when more frames fall in the window we downsample, preferring high-motion moments when Pillow is available.",
11115
+ "inputSchema": {"type": "object", "properties": {
11116
+ "at": {"type": "string", "description": "Video time to center on, MM:SS or seconds (e.g. '1:23'). Measured from screencast start."},
11117
+ "window": {"type": "string", "description": "Width of the window around `at`, e.g. '5s', '500ms'.", "default": "5s"},
11118
+ "last": {"type": "string", "description": "Return frames from the last N seconds (e.g. '5s'). Relative to the newest frame on disk."},
11119
+ "since_last": {"type": "boolean", "description": "Return only frames newer than the previous watch_query call."},
11120
+ "max": {"type": "integer", "description": "Maximum number of frame paths to return (downsampled if window has more).", "default": 16}
11121
+ }}},
11122
+ {"name": "browser_watch_status", "description": "Report the current screencast daemon state: running flag, frame count, total disk usage, oldest/newest timestamps. Use before browser_watch_query to confirm frames are actually being captured.",
11123
+ "inputSchema": {"type": "object", "properties": {}}},
11124
+ {"name": "browser_mode", "description": "Get or set the three-tier stealth mode (crawl4ai-style escalation). Tiers from lightest to heaviest fingerprint footprint: 'regular' injects NO anti-fingerprint patch (cleanest, fastest, fewest leaks — the default and best for most sites); 'stealth' injects a light patch (navigator.webdriver, chrome.runtime, permissions only — deliberately omits plugin spoofing which leaks); 'undetected' injects the full patch (light + plugin array + WebGL vendor + Worker patch — highest plausibility on naive checks but highest entropy). Omit 'tier' to read the current mode. Effect applies on the next navigation. Escalate to 'undetected' only for hard anti-bot targets.",
11125
+ "inputSchema": {"type": "object", "properties": {"tier": {"type": "string", "enum": ["regular", "stealth", "undetected"], "description": "Tier to set. Omit to get the current tier."}}}},
9610
11126
  ]
9611
11127
 
9612
11128
  def _handle_request(self, request):
@@ -9657,12 +11173,15 @@ class MCPServer:
9657
11173
  "browser_a11y": lambda a: ["a11y"] + ([a["mode"]] if a.get("mode") and a["mode"] != "full" else []),
9658
11174
  "browser_fill": lambda a: ["fill", a.get("selector", ""), a.get("value", "")],
9659
11175
  "browser_launch": lambda a: ["launch"],
9660
- "browser_close": lambda a: ["close"],
11176
+ "browser_close": lambda a: ["close"] + (["--force"] if a.get("force") else []) + (["--keep"] if a.get("keep_browser") else []),
9661
11177
  "browser_extract": lambda a: ["extract", a.get("selector", "")] + ([f"--{a['format']}"] if a.get("format") and a["format"] != "text" else []),
9662
11178
  "browser_observe": lambda a: ["observe"],
9663
11179
  "browser_smart_click": lambda a: ["smart-click", a.get("text", "")],
9664
11180
  "browser_dismiss": lambda a: ["dismiss"] + ([str(a["repeat"])] if a.get("repeat") else []),
9665
11181
  "browser_smart_fill": lambda a: ["smart-fill", a.get("label", ""), a.get("value", "")],
11182
+ "browser_captcha_solve": lambda a: ["captcha-solve"] + ([f"--provider={a['provider']}"] if a.get("provider") else []),
11183
+ "browser_press_hold": lambda a: ["press-hold"] + ([a["selector"]] if a.get("selector") else []),
11184
+ "browser_friction": lambda a: ["friction"],
9666
11185
  "browser_smart_select": lambda a: ["smart-select", a.get("label", ""), a.get("option", "")],
9667
11186
  "browser_describe": lambda a: ["describe"],
9668
11187
  "browser_assert": lambda a: ["assert", a.get("selector", "")] + ([a["text"]] if a.get("text") else []),
@@ -9677,6 +11196,11 @@ class MCPServer:
9677
11196
  "browser_assert_visible": lambda a: ["assert-visible", a.get("selector", "")],
9678
11197
  "browser_assert_hidden": lambda a: ["assert-hidden", a.get("selector", "")],
9679
11198
  "browser_screenshot_diff": lambda a: ["screenshot-diff", a.get("path1", ""), a.get("path2", "")],
11199
+ "browser_watch_start": lambda a: ["watch", "start"] + ([a["url"]] if a.get("url") else []) + ([f"--fps={a['fps']}"] if a.get("fps") else []) + ([f"--quality={a['quality']}"] if a.get("quality") else []) + ([f"--max-width={a['max_width']}"] if a.get("max_width") else []) + ([f"--retention={a['retention_s']}"] if a.get("retention_s") else []) + ([f"--disk-cap={a['disk_cap_mb']}"] if a.get("disk_cap_mb") else []) + ([f"--seek={a['seek']}"] if a.get("seek") else []),
11200
+ "browser_watch_stop": lambda a: ["watch", "stop"] + (["--keep-frames"] if a.get("keep_frames") else []),
11201
+ "browser_watch_query": lambda a: ["watch", "query"] + ([f"--at={a['at']}"] if a.get("at") else []) + ([f"--window={a['window']}"] if a.get("window") else []) + ([f"--last={a['last']}"] if a.get("last") else []) + (["--since-last"] if a.get("since_last") else []) + ([f"--max={a['max']}"] if a.get("max") else []),
11202
+ "browser_watch_status": lambda a: ["watch", "status"],
11203
+ "browser_mode": lambda a: ["mode"] + ([a["tier"]] if a.get("tier") else []),
9680
11204
  }
9681
11205
  if tool_name not in tool_map:
9682
11206
  return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32602, "message": f"Unknown tool: {tool_name}"}}
@@ -10409,25 +11933,848 @@ def _dispatch_blog_cmd(args):
10409
11933
  # ─── End Blog Publish Namespace ───────────────────────────────────────────────
10410
11934
 
10411
11935
 
10412
- # ─── CLI ───
11936
+ # ─── cdpilot watch — continuous screencast for AI video understanding ─────────
11937
+ #
11938
+ # Why this exists: still screenshots taken at command-rate intervals (~1s)
11939
+ # cannot capture animation, mouse cursors, scroll dynamics, or short visual
11940
+ # events ("kedi sola mı sağa mı koştu?"). CDP's built-in Page.startScreencast
11941
+ # streams JPEG frames at 10-30fps directly from the renderer. We tee the
11942
+ # stream onto a disk ring buffer so AI orchestrators (Claude, etc.) can query
11943
+ # any time window after the fact and get real frames to feed into a
11944
+ # multimodal API. Two-process design: a long-lived daemon owns the WS and
11945
+ # writes frames; foreground `query/status/stop` commands only read disk.
10413
11946
 
10414
- if __name__ == "__main__":
10415
- if len(sys.argv) < 2:
10416
- print(__doc__)
10417
- sys.exit(0)
11947
+ WATCH_DEFAULT_FPS = 10
11948
+ WATCH_DEFAULT_QUALITY = 70
11949
+ WATCH_DEFAULT_MAX_WIDTH = 1280
11950
+ WATCH_DEFAULT_RETENTION_S = 300 # 5 minutes
11951
+ WATCH_DEFAULT_DISK_CAP_MB = 100
11952
+ WATCH_DAEMON_FLAG = '--_watch-daemon' # hidden — used only for re-entrant fork
10418
11953
 
10419
- cmd = sys.argv[1]
10420
- args = sys.argv[2:]
10421
11954
 
10422
- # POSIX-style flag aliases — accepted at the Python entry point too,
10423
- # not just at the Node wrapper. Lets `python src/cdpilot.py --version`
10424
- # work in CI and for users who skip the Node wrapper entirely.
10425
- if cmd in ('--version', '-v'):
10426
- cmd = 'version'
11955
+ def _watch_dir():
11956
+ """Return per-project watch directory (state + frames + index)."""
11957
+ pid = PROJECT_ID or _get_project_id()
11958
+ return os.path.join(CDPILOT_HOME, 'projects', pid, 'watch')
11959
+
11960
+
11961
+ def _watch_frames_dir():
11962
+ return os.path.join(_watch_dir(), 'frames')
11963
+
11964
+
11965
+ def _watch_state_path():
11966
+ return os.path.join(_watch_dir(), 'state.json')
11967
+
11968
+
11969
+ def _watch_index_path():
11970
+ return os.path.join(_watch_dir(), 'index.jsonl')
11971
+
11972
+
11973
+ def _watch_log_path():
11974
+ return os.path.join(_watch_dir(), 'daemon.log')
11975
+
11976
+
11977
+ def _watch_load_state():
11978
+ try:
11979
+ with open(_watch_state_path()) as f:
11980
+ return json.load(f)
11981
+ except (OSError, ValueError):
11982
+ return None
11983
+
11984
+
11985
+ def _watch_save_state(state):
11986
+ os.makedirs(_watch_dir(), exist_ok=True)
11987
+ tmp = _watch_state_path() + '.tmp'
11988
+ with open(tmp, 'w') as f:
11989
+ json.dump(state, f, indent=2)
11990
+ os.replace(tmp, _watch_state_path())
11991
+
11992
+
11993
+ def _watch_clear_state():
11994
+ for p in (_watch_state_path(), _watch_index_path()):
11995
+ try:
11996
+ os.remove(p)
11997
+ except OSError:
11998
+ pass
11999
+
12000
+
12001
+ def _watch_pid_alive(pid):
12002
+ if not pid:
12003
+ return False
12004
+ try:
12005
+ os.kill(int(pid), 0)
12006
+ return True
12007
+ except (OSError, ValueError):
12008
+ return False
12009
+
12010
+
12011
+ def _watch_parse_timecode(spec):
12012
+ """Parse '1:23', '83', '1:23.5' into seconds (float). Returns None on failure."""
12013
+ if spec is None:
12014
+ return None
12015
+ s = str(spec).strip()
12016
+ if not s:
12017
+ return None
12018
+ try:
12019
+ if ':' in s:
12020
+ mm, ss = s.split(':', 1)
12021
+ return float(mm) * 60 + float(ss)
12022
+ return float(s)
12023
+ except ValueError:
12024
+ return None
12025
+
12026
+
12027
+ def _watch_parse_window(spec):
12028
+ """Parse '5s', '2.5', '500ms' into seconds (float). Returns 5.0 on failure."""
12029
+ if spec is None:
12030
+ return 5.0
12031
+ s = str(spec).strip().lower()
12032
+ if not s:
12033
+ return 5.0
12034
+ try:
12035
+ if s.endswith('ms'):
12036
+ return float(s[:-2]) / 1000.0
12037
+ if s.endswith('s'):
12038
+ return float(s[:-1])
12039
+ return float(s)
12040
+ except ValueError:
12041
+ return 5.0
12042
+
12043
+
12044
+ def _watch_evict(retention_s, disk_cap_bytes):
12045
+ """Drop frames older than retention OR over disk cap (oldest-first).
12046
+
12047
+ Returns (removed_count, bytes_freed).
12048
+ """
12049
+ fdir = _watch_frames_dir()
12050
+ if not os.path.isdir(fdir):
12051
+ return 0, 0
12052
+ now_ms = int(time.time() * 1000)
12053
+ cutoff_ms = now_ms - int(retention_s * 1000)
12054
+
12055
+ files = []
12056
+ for name in os.listdir(fdir):
12057
+ if not name.endswith('.jpg'):
12058
+ continue
12059
+ try:
12060
+ ts_ms = int(name[:-4])
12061
+ except ValueError:
12062
+ continue
12063
+ path = os.path.join(fdir, name)
12064
+ try:
12065
+ size = os.path.getsize(path)
12066
+ except OSError:
12067
+ continue
12068
+ files.append((ts_ms, path, size))
12069
+ files.sort() # oldest first
12070
+
12071
+ removed = 0
12072
+ freed = 0
12073
+ # Age-based eviction
12074
+ keep = []
12075
+ for ts_ms, path, size in files:
12076
+ if ts_ms < cutoff_ms:
12077
+ try:
12078
+ os.remove(path)
12079
+ removed += 1
12080
+ freed += size
12081
+ except OSError:
12082
+ pass
12083
+ else:
12084
+ keep.append((ts_ms, path, size))
12085
+
12086
+ # Disk-cap eviction (drop oldest until under cap)
12087
+ total = sum(s for _, _, s in keep)
12088
+ while keep and total > disk_cap_bytes:
12089
+ ts_ms, path, size = keep.pop(0)
12090
+ try:
12091
+ os.remove(path)
12092
+ removed += 1
12093
+ freed += size
12094
+ total -= size
12095
+ except OSError:
12096
+ pass
12097
+
12098
+ return removed, freed
12099
+
12100
+
12101
+ def _watch_list_frames():
12102
+ """Scan the ring buffer dir, return sorted list of (ts_ms, path)."""
12103
+ fdir = _watch_frames_dir()
12104
+ out = []
12105
+ if not os.path.isdir(fdir):
12106
+ return out
12107
+ for name in os.listdir(fdir):
12108
+ if not name.endswith('.jpg'):
12109
+ continue
12110
+ try:
12111
+ ts_ms = int(name[:-4])
12112
+ except ValueError:
12113
+ continue
12114
+ out.append((ts_ms, os.path.join(fdir, name)))
12115
+ out.sort()
12116
+ return out
12117
+
12118
+
12119
+ def _watch_frame_diff(a_path, b_path):
12120
+ """Mean absolute pixel difference (0.0..1.0). Returns None if PIL missing.
12121
+
12122
+ Graceful degradation: if PIL/Pillow is not installed we return None and
12123
+ callers fall back to uniform frame picking.
12124
+ """
12125
+ try:
12126
+ from PIL import Image, ImageChops, ImageStat # type: ignore
12127
+ except ImportError:
12128
+ return None
12129
+ try:
12130
+ with Image.open(a_path) as a_img, Image.open(b_path) as b_img:
12131
+ a_g = a_img.convert('L').resize((160, 90))
12132
+ b_g = b_img.convert('L').resize((160, 90))
12133
+ diff = ImageChops.difference(a_g, b_g)
12134
+ stat = ImageStat.Stat(diff)
12135
+ return float(stat.mean[0]) / 255.0
12136
+ except Exception:
12137
+ return None
12138
+
12139
+
12140
+ # ─── watch daemon (the actual screencast consumer) ───
12141
+
12142
+ async def _watch_daemon_run(url, fps, quality, max_width, retention_s,
12143
+ disk_cap_bytes, seek_s):
12144
+ """Long-lived process: open WS, subscribe to screencastFrame, write JPEGs.
12145
+
12146
+ Runs until SIGTERM / parent kill. Each frame:
12147
+ 1. base64-decode payload
12148
+ 2. write to frames/<unix_ms>.jpg
12149
+ 3. ACK with Page.screencastFrameAck (required, else stream stalls)
12150
+ 4. periodically evict (every ~2s)
12151
+ """
12152
+ import websockets
12153
+
12154
+ fdir = _watch_frames_dir()
12155
+ os.makedirs(fdir, exist_ok=True)
12156
+
12157
+ # Launch browser if needed
12158
+ if not cdp_get("/json/version"):
12159
+ cmd_launch()
12160
+
12161
+ ws_url, _ = get_page_ws()
12162
+
12163
+ # Navigate first (so the page exists before we start screencasting).
12164
+ # If url is empty we just attach to whatever is currently loaded.
12165
+ if url:
12166
+ try:
12167
+ await navigate_collect(ws_url, url, glow=False)
12168
+ except Exception as e:
12169
+ sys.stderr.write(f"watch: navigate failed: {e}\n")
12170
+
12171
+ # Try to coax a <video> element into autoplay + (optional) seek.
12172
+ # Best-effort — silent if the page has no video.
12173
+ autoplay_js = (
12174
+ "(()=>{const v=document.querySelector('video');"
12175
+ "if(!v) return {ok:false,reason:'no-video'};"
12176
+ "try{v.muted=true;v.playsInline=true;"
12177
+ + (f"v.currentTime={float(seek_s)};" if seek_s is not None else "")
12178
+ + "const p=v.play();if(p&&p.catch)p.catch(()=>{});"
12179
+ "return {ok:true,duration:v.duration||0,currentTime:v.currentTime};}"
12180
+ "catch(e){return {ok:false,reason:String(e)};}})()"
12181
+ )
12182
+ try:
12183
+ await cdp_send(ws_url, [(81, "Runtime.evaluate", {
12184
+ "expression": autoplay_js, "returnByValue": True,
12185
+ })])
12186
+ except Exception:
12187
+ pass
12188
+
12189
+ # everyNthFrame: CDP samples at ~60fps native; nth=6 → ~10fps.
12190
+ every_nth = max(1, int(round(60 / max(1, fps))))
12191
+
12192
+ # We bypass the pooled cdp_send for the screencast loop because we need
12193
+ # to consume *unsolicited* event frames (Page.screencastFrame) for the
12194
+ # entire daemon lifetime. The pool model assumes request/response.
12195
+ async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
12196
+ await ws.send(json.dumps({"id": 1, "method": "Page.enable", "params": {}}))
12197
+ await ws.send(json.dumps({
12198
+ "id": 2, "method": "Page.startScreencast",
12199
+ "params": {
12200
+ "format": "jpeg",
12201
+ "quality": int(quality),
12202
+ "maxWidth": int(max_width),
12203
+ "everyNthFrame": every_nth,
12204
+ },
12205
+ }))
12206
+
12207
+ last_evict = time.time()
12208
+ frame_count = 0
12209
+ # Update state file with confirmed start so `status` shows "running"
12210
+ st = _watch_load_state() or {}
12211
+ st['screencast_started_at_ms'] = int(time.time() * 1000)
12212
+ _watch_save_state(st)
12213
+
12214
+ while True:
12215
+ try:
12216
+ resp = await asyncio.wait_for(ws.recv(), timeout=5)
12217
+ except asyncio.TimeoutError:
12218
+ # Periodic eviction even if no frames arrive (e.g. page paused)
12219
+ if time.time() - last_evict > 2.0:
12220
+ _watch_evict(retention_s, disk_cap_bytes)
12221
+ last_evict = time.time()
12222
+ continue
12223
+ try:
12224
+ data = json.loads(resp)
12225
+ except ValueError:
12226
+ continue
12227
+ if data.get("method") != "Page.screencastFrame":
12228
+ continue
12229
+ params = data.get("params", {})
12230
+ session_id = params.get("sessionId")
12231
+ b64 = params.get("data", "")
12232
+ ts_ms = int(time.time() * 1000)
12233
+
12234
+ try:
12235
+ raw = base64.b64decode(b64)
12236
+ except Exception:
12237
+ raw = b""
12238
+ if raw:
12239
+ fpath = os.path.join(fdir, f"{ts_ms}.jpg")
12240
+ try:
12241
+ with open(fpath, 'wb') as f:
12242
+ f.write(raw)
12243
+ # Append to index (best-effort; missing entries are fine,
12244
+ # query falls back to scanning the dir directly)
12245
+ try:
12246
+ with open(_watch_index_path(), 'a') as idx:
12247
+ idx.write(json.dumps({
12248
+ "ts_ms": ts_ms,
12249
+ "filename": f"{ts_ms}.jpg",
12250
+ "size": len(raw),
12251
+ }) + "\n")
12252
+ except OSError:
12253
+ pass
12254
+ frame_count += 1
12255
+ except OSError as e:
12256
+ sys.stderr.write(f"watch: write failed: {e}\n")
12257
+
12258
+ # ACK is REQUIRED — CDP stops sending frames until acknowledged.
12259
+ try:
12260
+ await ws.send(json.dumps({
12261
+ "id": 1000 + (frame_count % 10000),
12262
+ "method": "Page.screencastFrameAck",
12263
+ "params": {"sessionId": session_id},
12264
+ }))
12265
+ except Exception:
12266
+ break
12267
+
12268
+ # Periodic eviction (every ~2s)
12269
+ if time.time() - last_evict > 2.0:
12270
+ _watch_evict(retention_s, disk_cap_bytes)
12271
+ last_evict = time.time()
12272
+
12273
+
12274
+ def _cmd_watch_daemon_entry():
12275
+ """Internal: re-entrant entry point when called with --_watch-daemon flag.
12276
+
12277
+ Reads parameters from state file, runs the async screencast loop forever.
12278
+ Logs go to daemon.log so the foreground process can stay clean.
12279
+ """
12280
+ state = _watch_load_state() or {}
12281
+ url = state.get('url') or ''
12282
+ fps = int(state.get('fps') or WATCH_DEFAULT_FPS)
12283
+ quality = int(state.get('quality') or WATCH_DEFAULT_QUALITY)
12284
+ max_width = int(state.get('max_width') or WATCH_DEFAULT_MAX_WIDTH)
12285
+ retention_s = float(state.get('retention_s') or WATCH_DEFAULT_RETENTION_S)
12286
+ disk_cap_bytes = int(state.get('disk_cap_bytes') or WATCH_DEFAULT_DISK_CAP_MB * 1024 * 1024)
12287
+ seek_s = state.get('seek_s')
12288
+
12289
+ state['pid'] = os.getpid()
12290
+ state['daemon_started_at_ms'] = int(time.time() * 1000)
12291
+ _watch_save_state(state)
12292
+
12293
+ try:
12294
+ asyncio.run(_watch_daemon_run(
12295
+ url, fps, quality, max_width, retention_s, disk_cap_bytes, seek_s
12296
+ ))
12297
+ except KeyboardInterrupt:
12298
+ pass
12299
+ except Exception as e:
12300
+ try:
12301
+ with open(_watch_log_path(), 'a') as f:
12302
+ f.write(f"[{datetime.datetime.utcnow().isoformat()}] daemon crash: {e}\n")
12303
+ except OSError:
12304
+ pass
12305
+
12306
+
12307
+ def cmd_watch_start(*args):
12308
+ """Start screencast capture in a background daemon.
12309
+
12310
+ cdpilot watch start <url|file://path|-> [--fps=10] [--quality=70]
12311
+ [--max-width=1280]
12312
+ [--retention=300]
12313
+ [--disk-cap=100]
12314
+ [--seek=1:23]
12315
+
12316
+ - URL may be omitted to attach to the currently-loaded page.
12317
+ - `-` as URL also means "attach, don't navigate".
12318
+ - Idempotent: if a daemon for this project is already running it is
12319
+ stopped first (we never want two screencasts on the same target).
12320
+ """
12321
+ # Stop any prior daemon — two screencasts on one target would race.
12322
+ prev = _watch_load_state()
12323
+ if prev and _watch_pid_alive(prev.get('pid')):
12324
+ try:
12325
+ os.kill(int(prev['pid']), 15)
12326
+ except OSError:
12327
+ pass
12328
+ # brief wait so the WS releases
12329
+ for _ in range(20):
12330
+ if not _watch_pid_alive(prev.get('pid')):
12331
+ break
12332
+ time.sleep(0.05)
12333
+
12334
+ # Wipe ring buffer + index from any prior run (same project, different video)
12335
+ fdir = _watch_frames_dir()
12336
+ if os.path.isdir(fdir):
12337
+ for name in os.listdir(fdir):
12338
+ if name.endswith('.jpg'):
12339
+ try:
12340
+ os.remove(os.path.join(fdir, name))
12341
+ except OSError:
12342
+ pass
12343
+ try:
12344
+ os.remove(_watch_index_path())
12345
+ except OSError:
12346
+ pass
12347
+
12348
+ # Parse args
12349
+ url = ''
12350
+ fps = WATCH_DEFAULT_FPS
12351
+ quality = WATCH_DEFAULT_QUALITY
12352
+ max_width = WATCH_DEFAULT_MAX_WIDTH
12353
+ retention_s = WATCH_DEFAULT_RETENTION_S
12354
+ disk_cap_mb = WATCH_DEFAULT_DISK_CAP_MB
12355
+ seek_s = None
12356
+
12357
+ positional = []
12358
+ for a in args:
12359
+ if a.startswith('--fps='):
12360
+ try:
12361
+ fps = max(1, min(30, int(a.split('=', 1)[1])))
12362
+ except ValueError:
12363
+ pass
12364
+ elif a.startswith('--quality='):
12365
+ try:
12366
+ quality = max(1, min(100, int(a.split('=', 1)[1])))
12367
+ except ValueError:
12368
+ pass
12369
+ elif a.startswith('--max-width='):
12370
+ try:
12371
+ max_width = max(160, min(3840, int(a.split('=', 1)[1])))
12372
+ except ValueError:
12373
+ pass
12374
+ elif a.startswith('--retention='):
12375
+ try:
12376
+ retention_s = max(5, int(a.split('=', 1)[1]))
12377
+ except ValueError:
12378
+ pass
12379
+ elif a.startswith('--disk-cap='):
12380
+ try:
12381
+ disk_cap_mb = max(1, int(a.split('=', 1)[1]))
12382
+ except ValueError:
12383
+ pass
12384
+ elif a.startswith('--seek='):
12385
+ seek_s = _watch_parse_timecode(a.split('=', 1)[1])
12386
+ elif not a.startswith('--'):
12387
+ positional.append(a)
12388
+
12389
+ if positional:
12390
+ url = positional[0]
12391
+ if url == '-':
12392
+ url = ''
12393
+
12394
+ os.makedirs(_watch_frames_dir(), exist_ok=True)
12395
+ state = {
12396
+ 'url': url,
12397
+ 'fps': fps,
12398
+ 'quality': quality,
12399
+ 'max_width': max_width,
12400
+ 'retention_s': retention_s,
12401
+ 'disk_cap_bytes': disk_cap_mb * 1024 * 1024,
12402
+ 'seek_s': seek_s,
12403
+ 'started_at_ms': int(time.time() * 1000),
12404
+ 'project_id': PROJECT_ID,
12405
+ 'pid': None,
12406
+ }
12407
+ _watch_save_state(state)
12408
+
12409
+ # Fork the daemon. Use the same python + this script + the hidden flag.
12410
+ script = os.path.abspath(__file__)
12411
+ log_f = None
12412
+ try:
12413
+ log_f = open(_watch_log_path(), 'a')
12414
+ except OSError:
12415
+ log_f = subprocess.DEVNULL
12416
+
12417
+ env = os.environ.copy()
12418
+ env['CDPILOT_PROJECT_ID'] = PROJECT_ID or _get_project_id()
12419
+ # The daemon must use the same CDP port (same project's browser)
12420
+ env['CDP_PORT'] = str(CDP_PORT)
12421
+
12422
+ proc = subprocess.Popen(
12423
+ [sys.executable, script, WATCH_DAEMON_FLAG],
12424
+ stdout=log_f, stderr=log_f,
12425
+ env=env,
12426
+ start_new_session=True, # detach from this terminal's process group
12427
+ )
12428
+ state['pid'] = proc.pid
12429
+ _watch_save_state(state)
12430
+
12431
+ # Wait briefly for the daemon to confirm screencast start
12432
+ confirmed = False
12433
+ for _ in range(40): # up to ~2s
12434
+ time.sleep(0.05)
12435
+ st = _watch_load_state() or {}
12436
+ if st.get('screencast_started_at_ms'):
12437
+ confirmed = True
12438
+ break
12439
+ if not _watch_pid_alive(proc.pid):
12440
+ break
12441
+
12442
+ out = {
12443
+ 'ok': confirmed or _watch_pid_alive(proc.pid),
12444
+ 'pid': proc.pid,
12445
+ 'url': url or '(attach)',
12446
+ 'fps': fps,
12447
+ 'quality': quality,
12448
+ 'max_width': max_width,
12449
+ 'retention_s': retention_s,
12450
+ 'disk_cap_mb': disk_cap_mb,
12451
+ 'frames_dir': _watch_frames_dir(),
12452
+ 'state_path': _watch_state_path(),
12453
+ }
12454
+ print(json.dumps(out, indent=2))
12455
+ if not out['ok']:
12456
+ sys.exit(1)
12457
+
12458
+
12459
+ def cmd_watch_stop(*args):
12460
+ """Stop the watch daemon and optionally cleanup frames."""
12461
+ keep_frames = '--keep-frames' in args
12462
+ state = _watch_load_state()
12463
+ if not state:
12464
+ print(json.dumps({'ok': True, 'message': 'no active watch session'}))
12465
+ return
12466
+
12467
+ pid = state.get('pid')
12468
+ killed = False
12469
+ if pid and _watch_pid_alive(pid):
12470
+ try:
12471
+ os.kill(int(pid), 15)
12472
+ killed = True
12473
+ except OSError:
12474
+ pass
12475
+ for _ in range(40):
12476
+ if not _watch_pid_alive(pid):
12477
+ break
12478
+ time.sleep(0.05)
12479
+ if _watch_pid_alive(pid):
12480
+ try:
12481
+ os.kill(int(pid), 9)
12482
+ except OSError:
12483
+ pass
12484
+
12485
+ removed = 0
12486
+ if not keep_frames:
12487
+ fdir = _watch_frames_dir()
12488
+ if os.path.isdir(fdir):
12489
+ for name in os.listdir(fdir):
12490
+ if name.endswith('.jpg'):
12491
+ try:
12492
+ os.remove(os.path.join(fdir, name))
12493
+ removed += 1
12494
+ except OSError:
12495
+ pass
12496
+ try:
12497
+ os.remove(_watch_index_path())
12498
+ except OSError:
12499
+ pass
12500
+
12501
+ _watch_clear_state()
12502
+ print(json.dumps({
12503
+ 'ok': True,
12504
+ 'killed': killed,
12505
+ 'pid': pid,
12506
+ 'frames_removed': removed,
12507
+ 'kept_frames': keep_frames,
12508
+ }, indent=2))
12509
+
12510
+
12511
+ def cmd_watch_status(*args):
12512
+ """Report daemon state, frame count, disk usage."""
12513
+ state = _watch_load_state()
12514
+ frames = _watch_list_frames()
12515
+ total_size = sum(os.path.getsize(p) for _, p in frames if os.path.exists(p)) if frames else 0
12516
+
12517
+ out = {
12518
+ 'running': bool(state and _watch_pid_alive(state.get('pid'))),
12519
+ 'frames': len(frames),
12520
+ 'disk_bytes': total_size,
12521
+ 'disk_mb': round(total_size / (1024 * 1024), 2),
12522
+ 'oldest_ts_ms': frames[0][0] if frames else None,
12523
+ 'newest_ts_ms': frames[-1][0] if frames else None,
12524
+ }
12525
+ if state:
12526
+ out['pid'] = state.get('pid')
12527
+ out['url'] = state.get('url') or '(attach)'
12528
+ out['fps'] = state.get('fps')
12529
+ out['started_at_ms'] = state.get('started_at_ms')
12530
+ out['screencast_started_at_ms'] = state.get('screencast_started_at_ms')
12531
+ out['retention_s'] = state.get('retention_s')
12532
+ out['disk_cap_mb'] = (state.get('disk_cap_bytes') or 0) // (1024 * 1024)
12533
+ print(json.dumps(out, indent=2))
12534
+
12535
+
12536
+ def _watch_last_query_path():
12537
+ return os.path.join(_watch_dir(), 'last-query.json')
12538
+
12539
+
12540
+ def cmd_watch_query(*args):
12541
+ """Return frame paths inside a time window as JSON.
12542
+
12543
+ cdpilot watch query --at <mm:ss|sec> --window <Ns> [--max=16]
12544
+ cdpilot watch query --since-last [--max=16]
12545
+ cdpilot watch query --last <Ns> [--max=16] (relative to newest frame)
12546
+
12547
+ Time mapping: --at is interpreted as VIDEO time, measured from the moment
12548
+ the screencast started (state['screencast_started_at_ms']). This matches
12549
+ user intuition for "what happened at 0:35" because we begin capturing
12550
+ right after autoplay.
12551
+ """
12552
+ state = _watch_load_state()
12553
+ if not state:
12554
+ print(json.dumps({'frames': [], 'count': 0, 'error': 'no watch session'}))
12555
+ return
12556
+
12557
+ max_frames = 16
12558
+ at_s = None
12559
+ window_s = 5.0
12560
+ since_last = False
12561
+ last_window = None
12562
+
12563
+ args_list = list(args)
12564
+ i = 0
12565
+ while i < len(args_list):
12566
+ a = args_list[i]
12567
+ if a == '--at' and i + 1 < len(args_list):
12568
+ at_s = _watch_parse_timecode(args_list[i + 1])
12569
+ i += 2
12570
+ elif a.startswith('--at='):
12571
+ at_s = _watch_parse_timecode(a.split('=', 1)[1])
12572
+ i += 1
12573
+ elif a == '--window' and i + 1 < len(args_list):
12574
+ window_s = _watch_parse_window(args_list[i + 1])
12575
+ i += 2
12576
+ elif a.startswith('--window='):
12577
+ window_s = _watch_parse_window(a.split('=', 1)[1])
12578
+ i += 1
12579
+ elif a == '--max' and i + 1 < len(args_list):
12580
+ try:
12581
+ max_frames = max(1, min(64, int(args_list[i + 1])))
12582
+ except ValueError:
12583
+ pass
12584
+ i += 2
12585
+ elif a.startswith('--max='):
12586
+ try:
12587
+ max_frames = max(1, min(64, int(a.split('=', 1)[1])))
12588
+ except ValueError:
12589
+ pass
12590
+ i += 1
12591
+ elif a == '--since-last':
12592
+ since_last = True
12593
+ i += 1
12594
+ elif a == '--last' and i + 1 < len(args_list):
12595
+ last_window = _watch_parse_window(args_list[i + 1])
12596
+ i += 2
12597
+ elif a.startswith('--last='):
12598
+ last_window = _watch_parse_window(a.split('=', 1)[1])
12599
+ i += 1
12600
+ else:
12601
+ i += 1
12602
+
12603
+ frames = _watch_list_frames()
12604
+ if not frames:
12605
+ print(json.dumps({'frames': [], 'count': 0, 'duration_s': 0}))
12606
+ return
12607
+
12608
+ sc_start_ms = state.get('screencast_started_at_ms') or state.get('started_at_ms') or frames[0][0]
12609
+ selected = []
12610
+
12611
+ if since_last:
12612
+ last_q = 0
12613
+ try:
12614
+ with open(_watch_last_query_path()) as f:
12615
+ last_q = int(json.load(f).get('ts_ms') or 0)
12616
+ except (OSError, ValueError):
12617
+ last_q = 0
12618
+ selected = [(ts, p) for ts, p in frames if ts > last_q]
12619
+ elif last_window is not None:
12620
+ newest_ts = frames[-1][0]
12621
+ lo_ms = newest_ts - int(last_window * 1000)
12622
+ selected = [(ts, p) for ts, p in frames if ts >= lo_ms]
12623
+ elif at_s is not None:
12624
+ # Map video time to wall-clock ms
12625
+ center_ms = sc_start_ms + int(at_s * 1000)
12626
+ half_ms = int((window_s / 2.0) * 1000)
12627
+ lo_ms = center_ms - half_ms
12628
+ hi_ms = center_ms + half_ms
12629
+ selected = [(ts, p) for ts, p in frames if lo_ms <= ts <= hi_ms]
12630
+ else:
12631
+ # Default: the latest <window_s> seconds
12632
+ newest_ts = frames[-1][0]
12633
+ lo_ms = newest_ts - int(window_s * 1000)
12634
+ selected = [(ts, p) for ts, p in frames if ts >= lo_ms]
12635
+
12636
+ # Downsample to max_frames (uniform pick, keep first + last)
12637
+ if len(selected) > max_frames:
12638
+ # Try motion-aware picking when PIL is available
12639
+ motion_scores = []
12640
+ for j in range(1, len(selected)):
12641
+ d = _watch_frame_diff(selected[j - 1][1], selected[j][1])
12642
+ motion_scores.append(d if d is not None else 0.0)
12643
+ if motion_scores and any(s is not None for s in motion_scores):
12644
+ # Pick frames around the highest-motion peaks
12645
+ indices = sorted(range(len(motion_scores)),
12646
+ key=lambda k: motion_scores[k], reverse=True)
12647
+ pick_idx = sorted(set([0, len(selected) - 1] + indices[:max_frames - 2]))
12648
+ pick_idx = pick_idx[:max_frames]
12649
+ selected = [selected[k] for k in pick_idx]
12650
+ else:
12651
+ # Uniform downsample
12652
+ step = len(selected) / max_frames
12653
+ picks = [int(j * step) for j in range(max_frames)]
12654
+ picks[-1] = len(selected) - 1
12655
+ selected = [selected[k] for k in picks]
12656
+
12657
+ if selected:
12658
+ try:
12659
+ with open(_watch_last_query_path(), 'w') as f:
12660
+ json.dump({'ts_ms': selected[-1][0]}, f)
12661
+ except OSError:
12662
+ pass
12663
+
12664
+ duration_s = (selected[-1][0] - selected[0][0]) / 1000.0 if len(selected) >= 2 else 0.0
12665
+ out = {
12666
+ 'frames': [p for _, p in selected],
12667
+ 'timestamps_ms': [t for t, _ in selected],
12668
+ 'count': len(selected),
12669
+ 'duration_s': round(duration_s, 3),
12670
+ 'window_s': window_s,
12671
+ 'at_s': at_s,
12672
+ 'screencast_started_at_ms': sc_start_ms,
12673
+ }
12674
+ print(json.dumps(out, indent=2))
12675
+
12676
+
12677
+ def cmd_watch_ask(*args):
12678
+ """Tiny natural-language wrapper around `watch query`.
12679
+
12680
+ cdpilot watch ask "0:35'te kedi sola mı sağa mı koştu?"
12681
+ cdpilot watch ask "son 5 saniyede ne oldu"
12682
+
12683
+ We don't actually do LLM inference here — this command just parses a
12684
+ time window out of the question and emits the same JSON shape as
12685
+ `watch query` so the orchestrator (Claude) can pick up the frames and
12686
+ feed them to a multimodal API itself.
12687
+ """
12688
+ if not args:
12689
+ print(json.dumps({'error': 'question required'}))
12690
+ sys.exit(1)
12691
+ question = ' '.join(args)
12692
+ q_lower = question.lower()
12693
+
12694
+ # Find mm:ss or m:ss anywhere in the string
12695
+ m = _re.search(r'(\d+):(\d{1,2})(?:\.\d+)?', question)
12696
+ at_s = None
12697
+ if m:
12698
+ at_s = float(m.group(1)) * 60 + float(m.group(2))
12699
+
12700
+ # "son N saniye(de)" / "last N seconds"
12701
+ last_match = _re.search(r'(?:son|last)\s+(\d+(?:\.\d+)?)\s*(?:saniye|sec|seconds?|s\b)', q_lower)
12702
+ last_window = float(last_match.group(1)) if last_match else None
12703
+
12704
+ forward = []
12705
+ if at_s is not None:
12706
+ forward = ['--at', f'{int(at_s // 60)}:{at_s % 60:.2f}'.rstrip('0').rstrip('.'),
12707
+ '--window', '5s']
12708
+ elif last_window is not None:
12709
+ forward = ['--last', f'{last_window}s']
12710
+ else:
12711
+ forward = ['--last', '5s']
12712
+
12713
+ forward += ['--max', '8']
12714
+ cmd_watch_query(*forward)
12715
+
12716
+
12717
+ def _dispatch_watch_cmd(args):
12718
+ """Parse `cdpilot watch <sub> ...` and dispatch. Sync wrapper."""
12719
+ sub = args[0] if args else ''
12720
+ rest = args[1:]
12721
+ if not sub or sub in ('--help', '-h', 'help'):
12722
+ print("Usage: cdpilot watch <subcommand> [options]", file=sys.stderr)
12723
+ print("", file=sys.stderr)
12724
+ print("Subcommands:", file=sys.stderr)
12725
+ print(" start [url|-] [--fps=N] [--quality=N] [--max-width=N]", file=sys.stderr)
12726
+ print(" [--retention=SEC] [--disk-cap=MB] [--seek=MM:SS]", file=sys.stderr)
12727
+ print(" stop [--keep-frames]", file=sys.stderr)
12728
+ print(" query --at MM:SS --window 5s [--max=16]", file=sys.stderr)
12729
+ print(" query --since-last [--max=16]", file=sys.stderr)
12730
+ print(" query --last 5s [--max=16]", file=sys.stderr)
12731
+ print(" status", file=sys.stderr)
12732
+ print(" ask \"<natural-language question>\"", file=sys.stderr)
12733
+ sys.exit(0)
12734
+ if sub == 'start':
12735
+ cmd_watch_start(*rest)
12736
+ elif sub == 'stop':
12737
+ cmd_watch_stop(*rest)
12738
+ elif sub == 'query':
12739
+ cmd_watch_query(*rest)
12740
+ elif sub == 'status':
12741
+ cmd_watch_status(*rest)
12742
+ elif sub == 'ask':
12743
+ cmd_watch_ask(*rest)
12744
+ else:
12745
+ print(f"Unknown watch subcommand: {sub}. Run: cdpilot watch --help", file=sys.stderr)
12746
+ sys.exit(1)
12747
+
12748
+
12749
+ # ─── End cdpilot watch namespace ──────────────────────────────────────────────
12750
+
12751
+
12752
+ # ─── CLI ───
12753
+
12754
+ if __name__ == "__main__":
12755
+ if len(sys.argv) < 2:
12756
+ print(__doc__)
12757
+ sys.exit(0)
12758
+
12759
+ cmd = sys.argv[1]
12760
+ args = sys.argv[2:]
12761
+
12762
+ # POSIX-style flag aliases — accepted at the Python entry point too,
12763
+ # not just at the Node wrapper. Lets `python src/cdpilot.py --version`
12764
+ # work in CI and for users who skip the Node wrapper entirely.
12765
+ if cmd in ('--version', '-v'):
12766
+ cmd = 'version'
10427
12767
  elif cmd in ('--help', '-h'):
10428
12768
  print(__doc__)
10429
12769
  sys.exit(0)
10430
12770
 
12771
+ # Hidden re-entrant entry for the watch daemon. `cdpilot watch start`
12772
+ # forks this process with the flag so the screencast consumer runs in
12773
+ # the background while the foreground process returns immediately.
12774
+ if cmd == WATCH_DAEMON_FLAG:
12775
+ _cmd_watch_daemon_entry()
12776
+ sys.exit(0)
12777
+
10431
12778
  sync_cmds = {
10432
12779
  'launch': cmd_launch,
10433
12780
  'tabs': cmd_tabs,
@@ -10437,6 +12784,7 @@ if __name__ == "__main__":
10437
12784
  'proxy': lambda: cmd_proxy(*args),
10438
12785
  'headless': lambda: cmd_headless(args[0] if args else None),
10439
12786
  'stealth': lambda: cmd_stealth(args[0] if args else None),
12787
+ 'mode': lambda: cmd_mode(args[0] if args else None),
10440
12788
  'block': lambda: cmd_block(*args),
10441
12789
  'context': lambda: cmd_context(*args),
10442
12790
  'show': lambda: cmd_show(args[0] if args else None),
@@ -10458,6 +12806,7 @@ if __name__ == "__main__":
10458
12806
  'test': lambda: cmd_test_dispatch(args),
10459
12807
  'trace': lambda: cmd_trace_dispatch(args),
10460
12808
  'blog': lambda: _dispatch_blog_cmd(args),
12809
+ 'watch': lambda: _dispatch_watch_cmd(args),
10461
12810
  }
10462
12811
 
10463
12812
  if cmd == "serve":
@@ -10497,6 +12846,15 @@ if __name__ == "__main__":
10497
12846
  cmd_switch_tab(args[0])
10498
12847
  sys.exit(0)
10499
12848
 
12849
+ # `stop --smart` aliases the smart close (owned tabs + browser-if-empty).
12850
+ # Bare `stop` keeps its legacy full-kill behavior for backward compatibility.
12851
+ if cmd == "stop" and "--smart" in args:
12852
+ asyncio.run(cmd_close(
12853
+ force_browser="--force" in args,
12854
+ keep_browser=("--keep" in args or "--keep-browser" in args),
12855
+ ))
12856
+ sys.exit(0)
12857
+
10500
12858
  if cmd in sync_cmds:
10501
12859
  sync_cmds[cmd]()
10502
12860
  sys.exit(0)
@@ -10557,7 +12915,10 @@ if __name__ == "__main__":
10557
12915
  "emulate": lambda: (require_args(1, "emulate <device>"), None)[1] if not args else cmd_emulate(args[0]),
10558
12916
  "glow": lambda: cmd_glow(args[0] if args else "on"),
10559
12917
  "debug": lambda: cmd_debug(args[0] if args else None),
10560
- "close": cmd_close,
12918
+ "close": lambda: cmd_close(
12919
+ force_browser="--force" in args,
12920
+ keep_browser=("--keep" in args or "--keep-browser" in args),
12921
+ ),
10561
12922
  'new-tab': lambda: cmd_new_tab(args[0] if args else 'about:blank'),
10562
12923
  'close-tab': lambda: cmd_close_tab(args[0] if args else None),
10563
12924
  'pdf': lambda: cmd_pdf(args[0] if args else None),
@@ -10614,6 +12975,15 @@ if __name__ == "__main__":
10614
12975
  'captcha-check': cmd_captcha_check,
10615
12976
  'captcha-wait': lambda: cmd_captcha_wait(args[0] if args else None),
10616
12977
  'captcha': lambda: cmd_captcha_dispatch(args),
12978
+ 'friction': cmd_friction,
12979
+ 'captcha-solve': lambda: cmd_captcha_solve(
12980
+ next((a.split('=')[1] for a in args if a.startswith('--provider=')),
12981
+ args[args.index('--provider') + 1] if '--provider' in args and args.index('--provider') + 1 < len(args)
12982
+ else (next((a for a in args if not a.startswith('--')), None))),
12983
+ ),
12984
+ 'profile': lambda: cmd_profile_dispatch(args),
12985
+ 'press-hold': lambda: cmd_press_hold(
12986
+ next((a for a in args if not a.startswith('--')), None)),
10617
12987
  'agent': lambda: _dispatch_agent_cmd(args),
10618
12988
  }
10619
12989
 
@@ -10621,7 +12991,8 @@ if __name__ == "__main__":
10621
12991
  NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
10622
12992
  'dialog', 'download', 'throttle', 'permission', 'intercept',
10623
12993
  'batch', 'screenshot-diff', 'run',
10624
- 'captcha-check', 'captcha-wait', 'captcha',
12994
+ 'captcha-check', 'captcha-wait', 'captcha', 'friction',
12995
+ 'profile',
10625
12996
  'cookies'} # v0.6.1: cookies auto-config doesn't need browser
10626
12997
  # Clean up idle sessions before running any command
10627
12998
  _cleanup_idle_sessions()