cdpilot 0.8.0 → 0.9.1

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.1"
18
18
 
19
19
  import asyncio
20
20
  import atexit
@@ -35,13 +35,16 @@ import secrets
35
35
  import glob
36
36
  import datetime
37
37
  import concurrent.futures
38
+ import tempfile
38
39
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
39
40
 
40
41
  # ─── Project-Based Multi-Instance Configuration ───
41
42
  # Each project directory (cwd) gets its own browser instance with
42
43
  # a unique CDP port and isolated profile directory. Zero-config.
43
44
 
44
- CDPILOT_HOME = os.path.expanduser("~/.cdpilot")
45
+ # CDPILOT_HOME override lets tests (and advanced users) point the registry
46
+ # at an isolated directory instead of the real ~/.cdpilot.
47
+ CDPILOT_HOME = os.environ.get("CDPILOT_HOME") or os.path.expanduser("~/.cdpilot")
45
48
  REGISTRY_FILE = os.path.join(CDPILOT_HOME, "registry.json")
46
49
  CDPILOT_PORT_RANGE_START = 9222
47
50
  CDPILOT_PORT_RANGE_END = 9322
@@ -65,10 +68,19 @@ def _get_project_id():
65
68
 
66
69
 
67
70
  def _is_port_free(port):
68
- """Check if a port is available for binding."""
71
+ """Check if a port is available for binding.
72
+
73
+ On Windows, SO_REUSEADDR lets a socket bind on top of a port that another
74
+ socket is actively listening on, so a probe using it called every port
75
+ free (and registry cleanup pruned live entries). Exclusive use fails when
76
+ anything holds the port. POSIX SO_REUSEADDR only skips TIME_WAIT.
77
+ """
69
78
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
70
79
  try:
71
- s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
80
+ if os.name == "nt":
81
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
82
+ else:
83
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
72
84
  s.bind(("127.0.0.1", port))
73
85
  return True
74
86
  except OSError:
@@ -86,10 +98,26 @@ def _load_registry():
86
98
 
87
99
 
88
100
  def _save_registry(projects):
89
- """Write the global project registry."""
90
- os.makedirs(os.path.dirname(REGISTRY_FILE), exist_ok=True)
91
- with open(REGISTRY_FILE, 'w') as f:
92
- json.dump({"version": 1, "projects": projects}, f, indent=2)
101
+ """Write the global project registry atomically.
102
+
103
+ Writes to a temp file in the same directory and os.replace()s it into
104
+ place, so a concurrent cdpilot invocation reading registry.json always
105
+ sees either the fully-old or fully-new content — never a torn/partial
106
+ write (os.replace is atomic on both POSIX and Windows).
107
+ """
108
+ reg_dir = os.path.dirname(REGISTRY_FILE)
109
+ os.makedirs(reg_dir, exist_ok=True)
110
+ fd, tmp_path = tempfile.mkstemp(prefix='.registry-', suffix='.tmp', dir=reg_dir)
111
+ try:
112
+ with os.fdopen(fd, 'w') as f:
113
+ json.dump({"version": 1, "projects": projects}, f, indent=2)
114
+ os.replace(tmp_path, REGISTRY_FILE)
115
+ except BaseException:
116
+ try:
117
+ os.remove(tmp_path)
118
+ except OSError:
119
+ pass
120
+ raise
93
121
 
94
122
 
95
123
  def _register_project(project_id, port, profile_dir, pid=None):
@@ -108,25 +136,83 @@ def _register_project(project_id, port, profile_dir, pid=None):
108
136
  _save_registry(registry)
109
137
 
110
138
 
139
+ def _pid_alive(pid):
140
+ """True if a process with this pid exists. Never signals it.
141
+
142
+ Sending signal 0 is a harmless existence probe on POSIX, but on Windows
143
+ Python maps every signal other than CTRL_C/CTRL_BREAK to TerminateProcess:
144
+ the "probe" kills the process. Registry pids are stale by nature and may
145
+ have been reused by an unrelated program, so on Windows ask the kernel
146
+ through OpenProcess/GetExitCodeProcess instead. Defined this early because
147
+ it runs at import time (_resolve_project_config -> _allocate_port).
148
+ """
149
+ try:
150
+ pid = int(pid)
151
+ except (TypeError, ValueError):
152
+ return False
153
+ if pid <= 0:
154
+ return False
155
+ if os.name == "nt":
156
+ import ctypes
157
+ kernel32 = ctypes.windll.kernel32
158
+ handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
159
+ if not handle:
160
+ return False
161
+ try:
162
+ code = ctypes.c_ulong()
163
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
164
+ return False
165
+ return code.value == 259 # STILL_ACTIVE
166
+ finally:
167
+ kernel32.CloseHandle(handle)
168
+ try:
169
+ os.kill(pid, 0)
170
+ except ProcessLookupError:
171
+ return False
172
+ except PermissionError:
173
+ return True # exists, just owned by another user
174
+ except OSError:
175
+ return False
176
+ return True
177
+
178
+
179
+ def _is_registry_entry_dead(info):
180
+ """A registry entry is dead only when we can PROVE nothing is using it.
181
+
182
+ Dead = nothing is listening on its port AND (if a pid was recorded)
183
+ that process is no longer alive. If the port is currently bound, we
184
+ never call it dead even if our own bookkeeping (status/pid) looks
185
+ stale — it may be another project's live browser instance, and a
186
+ live entry must never be pruned.
187
+ """
188
+ port = info.get("port")
189
+ if port and not _is_port_free(port):
190
+ return False
191
+ pid = info.get("pid")
192
+ if pid and _pid_alive(pid):
193
+ return False
194
+ return True
195
+
196
+
111
197
  def _cleanup_registry():
112
- """Update status for dead processes and return cleaned registry."""
198
+ """Prune dead entries from the registry and return what's left.
199
+
200
+ Called before every port allocation (see _allocate_port) so the
201
+ registry never accumulates stale entries that starve the
202
+ 9222-9322 port range — the root cause of the "No free port" failure
203
+ documented in .claude/docs/vaat-denetimi-2026-09-27.md.
204
+ """
113
205
  registry = _load_registry()
114
- changed = False
115
- for pid_key, info in registry.items():
116
- if info.get("status") == "running":
117
- port = info.get("port")
118
- if port and _is_port_free(port):
119
- info["status"] = "stopped"
120
- info["pid"] = None
121
- changed = True
122
- if changed:
123
- _save_registry(registry)
124
- return registry
206
+ alive = {pid_key: info for pid_key, info in registry.items()
207
+ if not _is_registry_entry_dead(info)}
208
+ if len(alive) != len(registry):
209
+ _save_registry(alive)
210
+ return alive
125
211
 
126
212
 
127
213
  def _allocate_port(project_id):
128
214
  """Find a free port for the given project."""
129
- registry = _load_registry()
215
+ registry = _cleanup_registry()
130
216
 
131
217
  # Reuse existing port if still free
132
218
  if project_id in registry:
@@ -236,7 +322,80 @@ window.__cdpilot_waitFor = function(selector, timeout) {
236
322
  # - Idempotent: guarded by window.__cdpilot_stealth flag.
237
323
  # - Fails silently per-patch; one failure cannot break the page.
238
324
  # - Opt-in via `cdpilot stealth on`. Default OFF for backward compatibility.
239
- STEALTH_JS = r"""
325
+ #
326
+ # THREE-TIER MODEL (crawl4ai-style regular -> stealth -> undetected):
327
+ # - regular : NO fingerprint patch injected at all. Cleanest, fastest,
328
+ # relies only on --disable-blink-features=AutomationControlled
329
+ # set at launch. Lowest entropy -> fewest leaks. Default tier.
330
+ # - stealth : STEALTH_JS_LIGHT - webdriver + chrome.runtime + permissions
331
+ # reconcile ONLY. Deliberately OMITS the plugin-array spoof,
332
+ # which Stealth Bench V1 proved is itself a leak source
333
+ # (bot.sannysoft.com surfaced garbage plugin filenames like
334
+ # "rCJMteXy" from the synthetic PluginArray). Light tier is a
335
+ # strict subset of FULL.
336
+ # - undetected : STEALTH_JS_FULL - the full body (light subset + plugins +
337
+ # Worker patch + WebGL vendor + hardwareConcurrency). Highest
338
+ # plausibility on naive checks but highest entropy; pair with
339
+ # behavioral entropy/humanize for the hardest targets.
340
+ #
341
+ # STEALTH_JS retains the FULL body for backward compatibility - every existing
342
+ # reference (`cdpilot stealth on`, adaptive escalation) maps to undetected.
343
+
344
+ # Light tier: the SAFE subset. webdriver + chrome.runtime + permissions only.
345
+ # No plugin spoofing (bench leak), no WebGL vendor override, no Worker wrap.
346
+ STEALTH_JS_LIGHT = r"""
347
+ (function() {
348
+ 'use strict';
349
+ if (window.__cdpilot_stealth) return;
350
+ try { Object.defineProperty(window, '__cdpilot_stealth', {value: true, writable: false, configurable: false}); } catch(e) {}
351
+
352
+ // 1) navigator.webdriver - only patch if it's actually `true`.
353
+ try {
354
+ var wdValue;
355
+ try { wdValue = navigator.webdriver; } catch (_) {}
356
+ if (wdValue === true) {
357
+ Object.defineProperty(Navigator.prototype, 'webdriver', {
358
+ get: function() { return false; },
359
+ configurable: true
360
+ });
361
+ }
362
+ } catch (e) {}
363
+
364
+ // 2) window.chrome - real Chrome exposes chrome.runtime even without extensions
365
+ try {
366
+ if (!window.chrome) {
367
+ window.chrome = {};
368
+ }
369
+ if (!window.chrome.runtime) {
370
+ window.chrome.runtime = {
371
+ OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {},
372
+ PlatformNackArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}
373
+ };
374
+ }
375
+ if (typeof window.chrome.app === 'undefined') {
376
+ window.chrome.app = { isInstalled: false, InstallState: {}, RunningState: {} };
377
+ }
378
+ } catch (e) {}
379
+
380
+ // 3) Permissions.query - reconcile with Notification.permission (classic tell)
381
+ try {
382
+ if (navigator.permissions && navigator.permissions.query) {
383
+ var origQuery = navigator.permissions.query.bind(navigator.permissions);
384
+ navigator.permissions.query = function(p) {
385
+ if (p && p.name === 'notifications') {
386
+ return Promise.resolve({ state: Notification.permission, onchange: null });
387
+ }
388
+ return origQuery(p);
389
+ };
390
+ }
391
+ } catch (e) {}
392
+ })();
393
+ """
394
+
395
+ # Full tier (a.k.a. "undetected"): the complete body. Light subset + plugin
396
+ # array + Worker patch + WebGL vendor + hardwareConcurrency. STEALTH_JS is kept
397
+ # as an alias of FULL so every legacy reference still resolves.
398
+ STEALTH_JS_FULL = r"""
240
399
  (function() {
241
400
  'use strict';
242
401
  if (window.__cdpilot_stealth) return;
@@ -404,6 +563,11 @@ STEALTH_JS = r"""
404
563
  })();
405
564
  """
406
565
 
566
+ # Backward-compat alias: STEALTH_JS == the FULL (undetected) body. Every legacy
567
+ # reference (`cdpilot stealth on`, adaptive escalation, navigate inject) keeps
568
+ # resolving to the full patch set, exactly as before the three-tier split.
569
+ STEALTH_JS = STEALTH_JS_FULL
570
+
407
571
  # ─── CAPTCHA Detection ────────────────────────────────────────────────────────
408
572
  # Read-only DOM probe. Detects common CAPTCHA/challenge providers post-navigation.
409
573
  # Returns JSON string: {"detected": bool, "types": [...], "details": [...]}
@@ -461,6 +625,14 @@ CAPTCHA_DETECT_JS = r"""
461
625
  add('geetest', 'GeeTest');
462
626
  }
463
627
 
628
+ // Amazon classic image CAPTCHA ("Type the characters you see in this image")
629
+ // Solvable locally with the optional amazoncaptcha library (image OCR).
630
+ var amzInput = document.querySelector('#captchacharacters');
631
+ var amzImg = document.querySelector('img[src*="opfcaptcha"], img[src*="captcha"][src*="images-na.ssl-images-amazon.com"], form[action*="/errors/validateCaptcha"] img');
632
+ if (amzInput && amzImg) {
633
+ add('amazon-classic', 'Amazon', { img_src: amzImg.src, input_id: amzInput.id || 'captchacharacters' });
634
+ }
635
+
464
636
  return JSON.stringify(result);
465
637
  } catch (e) {
466
638
  return JSON.stringify({ detected: false, error: String(e) });
@@ -468,9 +640,127 @@ CAPTCHA_DETECT_JS = r"""
468
640
  })()
469
641
  """
470
642
 
643
+ # ─── Progressive-Resilience Escalation Ladder ───
644
+ # Real sites stack defenses incrementally (sahibinden.com pattern: random
645
+ # captcha -> rate-limit -> login-wall -> SMS/OTP). CAPTCHA_DETECT_JS only sees
646
+ # the captcha rung. FRICTION_DETECT_JS detects the OTHER friction rungs purely
647
+ # from the DOM (read-only) and reports the HIGHEST one present. Bilingual
648
+ # (Turkish + English) keyword heuristics — TR sites are first-class.
649
+ #
650
+ # Ladder (low -> high):
651
+ # none normal page
652
+ # rate_limited HTTP 429 markers, "too many requests" / "çok fazla istek"
653
+ # soft_captcha (handled by CAPTCHA_DETECT_JS, merged in _detect_friction)
654
+ # login_wall login form gating the real content (sign in / giriş yap)
655
+ # otp_sms verification-code / OTP / SMS input
656
+ # hard_block access denied / banned / 403 forbidden, no content
657
+ FRICTION_DETECT_JS = r"""
658
+ (function() {
659
+ try {
660
+ var out = { level: 'none', signals: [], detail: '' };
661
+ var bodyText = (document.body ? (document.body.innerText || '') : '').toLowerCase();
662
+ var titleText = (document.title || '').toLowerCase();
663
+ var hay = titleText + ' \n ' + bodyText;
664
+ var sample = hay.slice(0, 6000);
665
+ var hasAny = function(words) {
666
+ for (var i = 0; i < words.length; i++) {
667
+ if (sample.indexOf(words[i]) >= 0) return words[i];
668
+ }
669
+ return null;
670
+ };
671
+
672
+ // ── hard_block (highest) ──
673
+ var blockWords = [
674
+ 'access denied', 'access to this page has been denied', 'you have been blocked',
675
+ 'permanently banned', 'ip has been banned', '403 forbidden', 'forbidden',
676
+ 'erişim engellendi', 'erişiminiz engellendi', 'engellendiniz', 'yasaklandı',
677
+ 'reddedildi', 'banlandı'
678
+ ];
679
+ var blockHit = hasAny(blockWords);
680
+ // Only treat as a hard block when the page has almost no real content
681
+ // (block pages are typically tiny). Avoids false positives on articles
682
+ // that merely mention these words.
683
+ var thinPage = bodyText.replace(/\s+/g, ' ').trim().length < 600;
684
+
685
+ // ── otp_sms ──
686
+ var otpWords = [
687
+ 'verification code', 'one-time code', 'one time password', 'otp',
688
+ 'sms code', 'code sent to your phone', 'enter the code',
689
+ 'doğrulama kodu', 'onay kodu', 'tek kullanımlık', 'telefonunuza gönderilen',
690
+ 'sms ile gönderilen', 'sms doğrulama', 'kodu girin'
691
+ ];
692
+ var otpInput = document.querySelector(
693
+ 'input[autocomplete="one-time-code"], input[name*="otp" i], input[id*="otp" i], ' +
694
+ 'input[name*="code" i][maxlength], input[name*="sms" i], input[name*="verification" i], ' +
695
+ 'input[id*="verification" i], input[name*="dogrulama" i], input[id*="dogrulama" i]'
696
+ );
697
+ var otpWordHit = hasAny(otpWords);
698
+
699
+ // ── login_wall ──
700
+ var pwInput = document.querySelector('input[type="password"]');
701
+ var loginForm = document.querySelector(
702
+ 'form[action*="login" i], form[action*="signin" i], form[action*="giris" i], form[id*="login" i]'
703
+ );
704
+ var loginWords = [
705
+ 'sign in', 'log in', 'login to continue', 'please sign in', 'you must be logged in',
706
+ 'create an account to continue', 'sign in to continue',
707
+ 'giriş yap', 'oturum aç', 'giriş yapın', 'üye girişi', 'devam etmek için giriş',
708
+ 'giriş yapmalısınız', 'lütfen giriş yapın', 'hesabınıza giriş'
709
+ ];
710
+ var loginWordHit = hasAny(loginWords);
711
+
712
+ // ── rate_limited ──
713
+ var rateWords = [
714
+ 'too many requests', 'rate limit', 'rate limited', 'slow down',
715
+ 'you are being rate limited', 'request limit', 'try again later',
716
+ 'çok fazla istek', 'çok sık istek', 'lütfen yavaşlayın', 'yavaşlayın',
717
+ 'istek sınırı', 'çok hızlı', 'daha sonra tekrar deneyin', 'güvenlik doğrulaması'
718
+ ];
719
+ var rateWordHit = hasAny(rateWords);
720
+ // 429 surfaced into DOM (some sites print the status), or retry-after hint
721
+ var rate429 = /\b429\b/.test(sample) && (sample.indexOf('request') >= 0 || sample.indexOf('istek') >= 0 || rateWordHit);
722
+ var retryAfter = /retry[\s-]?after/.test(sample);
723
+
724
+ // Decide highest rung present.
725
+ if (blockHit && thinPage) {
726
+ out.level = 'hard_block';
727
+ out.signals.push('keyword:' + blockHit);
728
+ out.detail = 'Access denied / banned page with no real content.';
729
+ } else if (otpWordHit || otpInput) {
730
+ out.level = 'otp_sms';
731
+ if (otpWordHit) out.signals.push('keyword:' + otpWordHit);
732
+ if (otpInput) out.signals.push('input:otp');
733
+ out.detail = 'SMS/OTP verification prompt detected.';
734
+ } else if ((pwInput || loginForm) && loginWordHit) {
735
+ out.level = 'login_wall';
736
+ out.signals.push('keyword:' + loginWordHit);
737
+ if (pwInput) out.signals.push('input:password');
738
+ if (loginForm) out.signals.push('form:login');
739
+ out.detail = 'Login wall gating the page content.';
740
+ } else if (loginWordHit && loginForm) {
741
+ out.level = 'login_wall';
742
+ out.signals.push('keyword:' + loginWordHit);
743
+ out.signals.push('form:login');
744
+ out.detail = 'Login wall gating the page content.';
745
+ } else if (rateWordHit || rate429 || retryAfter) {
746
+ out.level = 'rate_limited';
747
+ if (rateWordHit) out.signals.push('keyword:' + rateWordHit);
748
+ if (rate429) out.signals.push('status:429');
749
+ if (retryAfter) out.signals.push('hint:retry-after');
750
+ out.detail = 'Rate-limit signal detected on page.';
751
+ }
752
+
753
+ return JSON.stringify(out);
754
+ } catch (e) {
755
+ return JSON.stringify({ level: 'none', signals: [], detail: '', error: String(e) });
756
+ }
757
+ })()
758
+ """
759
+
471
760
  PROXY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'proxy.json')
472
761
  HEADLESS_CONFIG_FILE = os.path.join(PROFILE_DIR, 'headless.json')
473
762
  STEALTH_CONFIG_FILE = os.path.join(PROFILE_DIR, 'stealth.json')
763
+ MODE_CONFIG_FILE = os.path.join(PROFILE_DIR, 'mode.json')
474
764
  BLOCK_CONFIG_FILE = os.path.join(PROFILE_DIR, 'block.json')
475
765
  CAPTCHA_PROVIDERS_FILE = os.path.join(CDPILOT_HOME, 'captcha-providers.json')
476
766
  CAPTCHA_AUTO_FILE = os.path.join(PROFILE_DIR, 'captcha-auto.json')
@@ -480,6 +770,10 @@ ADAPTIVE_CONFIG_FILE = os.path.join(PROFILE_DIR, 'adaptive.json')
480
770
  ENTROPY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'entropy.json')
481
771
  DOWNLOAD_CONFIG_FILE = os.path.join(PROFILE_DIR, 'download-config.json')
482
772
  SESSION_FILE = os.path.join(PROFILE_DIR, 'sessions.json')
773
+ # Tabs that cdpilot itself opened (via go / new-tab / smart-click). The smart
774
+ # `close` command only touches these — user tabs opened manually in the same
775
+ # isolated profile are left alone.
776
+ OWNED_TABS_FILE = os.path.join(PROFILE_DIR, 'owned-tabs.json')
483
777
  COOKIES_DIR = os.path.join(CDPILOT_HOME, 'cookies')
484
778
  COOKIES_AUTO_CONFIG_FILE = os.path.join(PROFILE_DIR, 'cookies-auto.json')
485
779
  CF_CLEARANCE_COOKIES = frozenset({'cf_clearance', '__cf_bm', '_cfuvid'})
@@ -1030,6 +1324,9 @@ def _create_session_window():
1030
1324
  "last_used": time.time(),
1031
1325
  }
1032
1326
  _save_sessions(sessions)
1327
+ # Freshly created by cdpilot -> owned. (The reuse branch above does not
1328
+ # mark, since that tab may have been opened by the user.)
1329
+ _mark_owned_tab(target_id)
1033
1330
 
1034
1331
  return target_id
1035
1332
 
@@ -1405,11 +1702,16 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
1405
1702
  # as long as this connection lives — which is exactly the window we
1406
1703
  # need (until Page.loadEventFired below). Stealth must run before
1407
1704
  # any page script inspects navigator; addScript guarantees that.
1408
- stealth_active = get_stealth_config()
1409
- if stealth_active:
1705
+ #
1706
+ # Three-tier selection: regular -> inject nothing, stealth -> LIGHT,
1707
+ # undetected -> FULL. get_mode_config() folds in the legacy stealth
1708
+ # toggle and the CDPILOT_STEALTH env var (set during adaptive
1709
+ # escalation) so existing behavior is preserved.
1710
+ stealth_source = stealth_js_for_tier(get_mode_config())
1711
+ if stealth_source:
1410
1712
  await ws.send(json.dumps({
1411
1713
  "id": 50, "method": "Page.addScriptToEvaluateOnNewDocument",
1412
- "params": {"source": STEALTH_JS}
1714
+ "params": {"source": stealth_source}
1413
1715
  }))
1414
1716
 
1415
1717
  # Apply request blocking BEFORE navigate, on this same WS session.
@@ -1959,6 +2261,105 @@ def get_headless_config():
1959
2261
  return False
1960
2262
 
1961
2263
 
2264
+ # ─── Three-tier stealth mode (crawl4ai-style escalation) ──────────────────────
2265
+ # Tiers, weakest fingerprint footprint -> strongest spoof:
2266
+ # regular -> inject NOTHING (cleanest, default)
2267
+ # stealth -> STEALTH_JS_LIGHT (webdriver/chrome.runtime/permissions only)
2268
+ # undetected -> STEALTH_JS_FULL (light + plugins + WebGL + Worker)
2269
+ MODE_TIERS = ('regular', 'stealth', 'undetected')
2270
+ DEFAULT_MODE_TIER = 'regular'
2271
+
2272
+
2273
+ def get_mode_config():
2274
+ """Return the active stealth tier as a string in MODE_TIERS.
2275
+
2276
+ Resolution order: CDPILOT_MODE env -> mode.json -> legacy stealth on/off
2277
+ -> DEFAULT_MODE_TIER. Legacy `cdpilot stealth on` (no mode.json present)
2278
+ maps to 'undetected' so the old binary toggle still produces the full
2279
+ patch set; `stealth off` (or absent) keeps 'regular'.
2280
+ """
2281
+ env = os.environ.get('CDPILOT_MODE', '').strip().lower()
2282
+ if env in MODE_TIERS:
2283
+ return env
2284
+ if os.path.exists(MODE_CONFIG_FILE):
2285
+ try:
2286
+ with open(MODE_CONFIG_FILE) as f:
2287
+ tier = json.load(f).get('tier', DEFAULT_MODE_TIER)
2288
+ if tier in MODE_TIERS:
2289
+ return tier
2290
+ except (OSError, ValueError):
2291
+ pass
2292
+ # Backwards-compat: no explicit mode set — honor the legacy stealth toggle.
2293
+ # stealth on -> undetected (full), stealth off -> regular (clean).
2294
+ return 'undetected' if get_stealth_config() else DEFAULT_MODE_TIER
2295
+
2296
+
2297
+ def set_mode_config(tier):
2298
+ """Persist the active tier to mode.json. Returns the normalized tier."""
2299
+ tier = (tier or '').strip().lower()
2300
+ if tier not in MODE_TIERS:
2301
+ raise ValueError(f"invalid tier: {tier!r}")
2302
+ _atomic_write_json(MODE_CONFIG_FILE, {'tier': tier})
2303
+ return tier
2304
+
2305
+
2306
+ def stealth_js_for_tier(tier):
2307
+ """Return the fingerprint patch JS for a tier, or None for 'regular'.
2308
+
2309
+ regular -> None (inject nothing)
2310
+ stealth -> STEALTH_JS_LIGHT
2311
+ undetected -> STEALTH_JS_FULL
2312
+ """
2313
+ if tier == 'stealth':
2314
+ return STEALTH_JS_LIGHT
2315
+ if tier == 'undetected':
2316
+ return STEALTH_JS_FULL
2317
+ return None
2318
+
2319
+
2320
+ def cmd_mode(tier=None):
2321
+ """Get or set the three-tier stealth mode (regular | stealth | undetected).
2322
+
2323
+ Usage:
2324
+ cdpilot mode # show current tier + what it injects
2325
+ cdpilot mode regular # no fingerprint patch (cleanest, fastest)
2326
+ cdpilot mode stealth # light patch: webdriver/chrome.runtime/perms
2327
+ cdpilot mode undetected # full patch: + plugins + WebGL + Worker
2328
+
2329
+ Default: regular. Stealth Bench V1 found that the full patch set ALONE
2330
+ lowered scores (-6.3p) because the synthetic plugin array leaks, while
2331
+ light host-learning gained (+6.3p). The 'stealth' tier omits plugin
2332
+ spoofing for this reason. Escalate to 'undetected' only for hard targets.
2333
+ Effect applies on the NEXT navigation. Env override: CDPILOT_MODE=<tier>.
2334
+ """
2335
+ if tier is None or tier.strip().lower() in ('', 'status'):
2336
+ current = get_mode_config()
2337
+ patch = {
2338
+ 'regular': 'none (no fingerprint patch — cleanest)',
2339
+ 'stealth': 'STEALTH_JS_LIGHT (webdriver, chrome.runtime, permissions)',
2340
+ 'undetected': 'STEALTH_JS_FULL (light + plugins + WebGL + Worker)',
2341
+ }[current]
2342
+ print(f'Mode: {current}')
2343
+ print(f' Injects: {patch}')
2344
+ return
2345
+ t = tier.strip().lower()
2346
+ if t not in MODE_TIERS:
2347
+ print(f"Invalid tier: {tier}. Use one of: {', '.join(MODE_TIERS)}.", file=sys.stderr)
2348
+ sys.exit(1)
2349
+ set_mode_config(t)
2350
+ # Keep the legacy stealth toggle coherent so `stealth status` and any code
2351
+ # still reading get_stealth_config() agree with the tier:
2352
+ # regular -> stealth off
2353
+ # stealth -> stealth on (a patch IS injected, just the light one)
2354
+ # undetected-> stealth on
2355
+ try:
2356
+ _atomic_write_json(STEALTH_CONFIG_FILE, {'stealth': t != 'regular'})
2357
+ except Exception:
2358
+ pass
2359
+ print(f'Mode: {t}')
2360
+ print('Effect applies on next navigation (`cdpilot go <url>`).')
2361
+
2362
+
1962
2363
  def get_stealth_config():
1963
2364
  """Return whether stealth fingerprint patches are enabled.
1964
2365
 
@@ -2012,6 +2413,31 @@ def _sanitize_profile_state(profile_dir):
2012
2413
 
2013
2414
  # ─── Commands ───
2014
2415
 
2416
+ def _minimize_browser_window():
2417
+ """Minimize the browser window via CDP (Browser.setWindowBounds).
2418
+
2419
+ Reliable on macOS where negative --window-position is clamped back on
2420
+ screen. Uses cdpilot's own async cdp_send over the browser-level WS —
2421
+ no new dependency. Best-effort: any failure is swallowed by the caller.
2422
+ """
2423
+ ver = cdp_get('/json/version') or {}
2424
+ ws_url = ver.get('webSocketDebuggerUrl')
2425
+ if not ws_url:
2426
+ return
2427
+
2428
+ async def _do():
2429
+ r = await cdp_send(ws_url, [(1, "Browser.getWindowForTarget", {})])
2430
+ win_id = ((r.get(1) or {}).get("result") or {}).get("windowId")
2431
+ if win_id is None:
2432
+ win_id = 1
2433
+ await cdp_send(ws_url, [(2, "Browser.setWindowBounds", {
2434
+ "windowId": win_id,
2435
+ "bounds": {"windowState": "minimized"},
2436
+ })])
2437
+
2438
+ asyncio.run(_do())
2439
+
2440
+
2015
2441
  def cmd_launch():
2016
2442
  """Launch the browser with CDP enabled (isolated session — does not touch existing browser)."""
2017
2443
  global CHROME_BIN, CDP_PORT, CDP_BASE
@@ -2098,6 +2524,12 @@ def cmd_launch():
2098
2524
  'InterestFeedContentSuggestions', 'CalculateNativeWinOcclusion',
2099
2525
  'PasswordManagerOnboarding', 'AutofillServerCommunication',
2100
2526
  ]),
2527
+ # Anti-bot leak fix (2026-05-31): Chrome's blink runtime exposes an
2528
+ # "automation controlled" flag when --remote-debugging-port is set;
2529
+ # Patchright/nodriver disable this. Even though navigator.webdriver
2530
+ # already reads false on vanilla Brave/Chrome, deeper detectors
2531
+ # (Cloudflare/Datadome behavioral) probe the blink flag directly.
2532
+ '--disable-blink-features=AutomationControlled',
2101
2533
  # ─── Chromium performance flags ───
2102
2534
  '--disable-background-networking',
2103
2535
  '--disable-background-timer-throttling',
@@ -2128,6 +2560,17 @@ def cmd_launch():
2128
2560
  '--new-window', 'about:blank',
2129
2561
  ]
2130
2562
 
2563
+ # Off-screen window placement (opt-in). Keeps the browser HEADED (anti-bot
2564
+ # detection still sees a real window / GPU) but parks it far off the visible
2565
+ # desktop so it never steals focus or pops in front of the user — ideal for
2566
+ # long bench runs on a workstation. CDPILOT_OFFSCREEN=1 or a custom
2567
+ # CDPILOT_WINDOW_POSITION="x,y". No effect on headless.
2568
+ _win_pos = os.environ.get('CDPILOT_WINDOW_POSITION', '')
2569
+ if not _win_pos and os.environ.get('CDPILOT_OFFSCREEN') in ('1', 'true', 'yes', 'on'):
2570
+ _win_pos = '-3000,-3000'
2571
+ if _win_pos:
2572
+ chrome_args.append(f'--window-position={_win_pos}')
2573
+
2131
2574
  # Dev extensions
2132
2575
  dev_exts = get_dev_extensions()
2133
2576
  valid_exts = [p for p in dev_exts if os.path.isdir(p)]
@@ -2154,6 +2597,16 @@ def cmd_launch():
2154
2597
  if cdp_get('/json/version'):
2155
2598
  if PROJECT_ID:
2156
2599
  _register_project(PROJECT_ID, CDP_PORT, PROFILE_DIR, pid=proc.pid)
2600
+ # Off-screen / minimized placement (opt-in). Keeps the browser
2601
+ # HEADED (anti-bot still sees a real GPU-backed window) but parks it
2602
+ # out of the way so long bench runs never steal focus or pop in
2603
+ # front of the user. macOS clamps negative --window-position, so we
2604
+ # also minimize via CDP (Browser.setWindowBounds) which is reliable.
2605
+ if os.environ.get('CDPILOT_OFFSCREEN') in ('1', 'true', 'yes', 'on'):
2606
+ try:
2607
+ _minimize_browser_window()
2608
+ except Exception:
2609
+ pass
2157
2610
  proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
2158
2611
  print(f'CDP ready! (port {CDP_PORT}){proj_label}')
2159
2612
  return
@@ -2161,7 +2614,16 @@ def cmd_launch():
2161
2614
  sys.exit(1)
2162
2615
 
2163
2616
 
2164
- def cmd_tabs():
2617
+ def cmd_tabs(reap=False, max_tabs=1, include_real=False, dry_run=False):
2618
+ if reap:
2619
+ result = _reap_tabs(max_tabs=max_tabs, include_real=include_real,
2620
+ dry_run=dry_run)
2621
+ verb = "would close" if result.get("dry_run") else "closed"
2622
+ print(f"{verb} {len(result['closed'])} tab(s), kept {result['kept']}"
2623
+ f" (blank {result.get('blank', 0)}, real {result.get('real', 0)})")
2624
+ if result.get("reason"):
2625
+ print(f" {result['reason']}")
2626
+ return
2165
2627
  tabs = get_tabs()
2166
2628
  pages = [t for t in tabs if t.get("type") == "page"]
2167
2629
  for i, p in enumerate(pages):
@@ -2177,6 +2639,9 @@ async def cmd_go(url):
2177
2639
  cmd_launch()
2178
2640
 
2179
2641
  ws, page = get_page_ws()
2642
+ # Mark this tab as cdpilot-owned so a later `close` knows it can shut it.
2643
+ if isinstance(page, dict):
2644
+ _mark_owned_tab(page.get("id"))
2180
2645
 
2181
2646
  try:
2182
2647
  from urllib.parse import urlparse
@@ -2188,12 +2653,20 @@ async def cmd_go(url):
2188
2653
  adaptive_stealth = False
2189
2654
  is_known_hostile = _adaptive_host_requires_stealth(url)
2190
2655
 
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'
2656
+ # Adaptive mode (tier-aware): if we learned a tier for this host, start the
2657
+ # navigation at that tier via CDPILOT_MODE. Falls back to the legacy
2658
+ # stealth_hosts list (-> 'stealth' tier) for hosts learned before tiers
2659
+ # existed. CDPILOT_MODE takes precedence in get_mode_config().
2660
+ learned_tier = _adaptive_host_tier(url)
2661
+ if learned_tier and learned_tier != 'regular' and get_mode_config() == 'regular':
2662
+ os.environ['CDPILOT_MODE'] = learned_tier
2195
2663
  adaptive_stealth = True
2196
- sys.stderr.write(f"🛡️ Adaptive: enabling stealth for known-hostile host\n")
2664
+ sys.stderr.write(f"🛡️ Adaptive: starting at learned tier '{learned_tier}' for known-hostile host\n")
2665
+ elif is_known_hostile and get_mode_config() == 'regular':
2666
+ # Legacy stealth_hosts entry with no tier learned yet — start at light.
2667
+ os.environ['CDPILOT_MODE'] = 'stealth'
2668
+ adaptive_stealth = True
2669
+ sys.stderr.write(f"🛡️ Adaptive: enabling stealth tier for known-hostile host\n")
2197
2670
 
2198
2671
  # Fix 1: Per-task context isolation (opt-in via CDPILOT_ADAPTIVE_FRESH_CONTEXT=1) — spawn a fresh BrowserContext when
2199
2672
  # adaptive is on and we know this host is hostile. Prevents cross-task
@@ -2231,6 +2704,44 @@ async def cmd_go(url):
2231
2704
  except Exception:
2232
2705
  pass
2233
2706
 
2707
+ # Progressive-resilience friction probe (non-captcha rungs).
2708
+ # rate_limited -> exponential backoff + re-nav (bounded). login/otp/hard
2709
+ # -> stderr handoff warning only (NO autonomous bypass — ethics line).
2710
+ # The captcha rung is left to the dedicated probe below.
2711
+ try:
2712
+ fr = await _detect_friction(active_ws)
2713
+ fr_level = fr.get('level', 'none')
2714
+ if fr_level == 'rate_limited' and _friction_backoff_enabled():
2715
+ attempt = 0
2716
+ while fr_level == 'rate_limited' and attempt < _friction_max_retry():
2717
+ wait_s = _friction_backoff_seconds(attempt)
2718
+ sys.stderr.write(
2719
+ f"⏳ Rate-limit tespit edildi ({','.join(fr.get('signals', [])) or '?'}); "
2720
+ f"{wait_s}s backoff, retry {attempt + 1}/{_friction_max_retry()}...\n")
2721
+ if expected_host:
2722
+ _adaptive_remember_host(expected_host)
2723
+ await asyncio.sleep(wait_s)
2724
+ await navigate_collect(active_ws, url)
2725
+ fr = await _detect_friction(active_ws)
2726
+ fr_level = fr.get('level', 'none')
2727
+ attempt += 1
2728
+ if fr_level == 'rate_limited':
2729
+ sys.stderr.write("⚠️ Rate-limit backoff sonrası hâlâ sınırlı. Daha sonra deneyin.\n")
2730
+ else:
2731
+ sys.stderr.write("✅ Rate-limit temizlendi.\n")
2732
+ elif fr_level in ('login_wall', 'otp_sms', 'hard_block'):
2733
+ act = _friction_action(fr_level)
2734
+ payload = {'action': act['action'], 'url': url, 'level': fr_level,
2735
+ 'signals': fr.get('signals', [])}
2736
+ if act.get('backoff_suggested'):
2737
+ payload['backoff_suggested'] = True
2738
+ # Human handoff: warn + emit JSON line, but DO NOT block the
2739
+ # navigation (caller may still want the partial content).
2740
+ sys.stderr.write(f"{act['message']}\n")
2741
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
2742
+ except Exception:
2743
+ pass
2744
+
2234
2745
  # Post-navigation CAPTCHA probe. Non-blocking: stderr warning only.
2235
2746
  try:
2236
2747
  info = await _detect_captcha(active_ws)
@@ -2238,20 +2749,26 @@ async def cmd_go(url):
2238
2749
  types = ",".join(info.get("types", [])) or "unknown"
2239
2750
  sys.stderr.write(f"⚠️ CAPTCHA tespit edildi ({types}). Çözüm için: cdpilot captcha-wait\n")
2240
2751
 
2241
- # Adaptive escalation: remember this host so the next visit
2242
- # starts in stealth. Re-nav is gated to once-per-call.
2752
+ # Adaptive escalation (tier-aware): bump the host one tier up
2753
+ # (regular -> stealth -> undetected), remember it, and re-nav
2754
+ # once at the new tier. Re-nav is gated to once-per-call.
2243
2755
  if cfg['enabled']:
2756
+ current_tier = get_mode_config()
2757
+ next_tier = _escalate_tier(current_tier)
2244
2758
  if expected_host:
2245
2759
  _adaptive_remember_host(expected_host)
2246
- sys.stderr.write(f"🛡️ Adaptive: remembered {expected_host} as stealth-required\n")
2760
+ _adaptive_remember_host_tier(expected_host, next_tier)
2761
+ sys.stderr.write(f"🛡️ Adaptive: remembered {expected_host} at tier '{next_tier}'\n")
2247
2762
  captcha_types = info.get('types', [])
2248
2763
  _adaptive_remember_host_entropy(expected_host, captcha_types)
2249
2764
  needs_entropy = any(CAPTCHA_ENTROPY_REQUIRED.get(t, True) for t in captcha_types)
2250
2765
  if needs_entropy:
2251
2766
  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'
2767
+ # Only retry if escalation actually changes the tier (not
2768
+ # already at the undetected ceiling for this navigation).
2769
+ if next_tier != current_tier:
2770
+ sys.stderr.write(f"🛡️ Adaptive: retrying once at tier '{next_tier}'...\n")
2771
+ os.environ['CDPILOT_MODE'] = next_tier
2255
2772
  # Fix 3: Idempotent re-nav — skip if already on target.
2256
2773
  current_host = await _adaptive_current_host(active_ws)
2257
2774
  norm = lambda h: h[4:] if h.startswith("www.") else h
@@ -2259,12 +2776,12 @@ async def cmd_go(url):
2259
2776
  sys.stderr.write("🛡️ Adaptive: already on target host, skip re-nav\n")
2260
2777
  else:
2261
2778
  await navigate_collect(active_ws, url)
2262
- # Re-probe after stealth re-nav
2779
+ # Re-probe after escalated re-nav
2263
2780
  info2 = await _detect_captcha(active_ws)
2264
2781
  if info2.get("detected"):
2265
- sys.stderr.write("⚠️ Adaptive: CAPTCHA still present after stealth retry. Manual solve needed.\n")
2782
+ sys.stderr.write(f"⚠️ Adaptive: CAPTCHA still present after '{next_tier}' retry. Manual solve needed.\n")
2266
2783
  else:
2267
- sys.stderr.write("✅ Adaptive: CAPTCHA cleared with stealth.\n")
2784
+ sys.stderr.write(f"✅ Adaptive: CAPTCHA cleared at tier '{next_tier}'.\n")
2268
2785
  # Captcha solver auto-mode: if provider configured + auto on, attempt solve
2269
2786
  try:
2270
2787
  await _captcha_auto_solve_if_enabled(active_ws, info, url)
@@ -2773,6 +3290,188 @@ async def _humanize_click(ws_url, x, y):
2773
3290
  await asyncio.sleep(r.uniform(0.08, 0.20))
2774
3291
 
2775
3292
 
3293
+ # ─── PerimeterX / HUMAN "Press & Hold" solver ───────────────────────────────
3294
+ # PerimeterX (HUMAN Security) gates content behind a button the user must press
3295
+ # AND HOLD for several seconds. The detector measures the mouse-hold *behaviour*
3296
+ # (press duration, micro-tremor during the hold, release timing) to tell a human
3297
+ # hand from a bot — it is NOT a token challenge, so there is nothing to fetch or
3298
+ # inject. The only way through is to emit a real, human-shaped press->hold->release
3299
+ # gesture. We reuse the existing humanized mouse path + Input.dispatchMouseEvent
3300
+ # (zero new deps, pure CDP) and add the natural hand-tremor PerimeterX looks for.
3301
+
3302
+ # JS that locates the press-and-hold target and returns its viewport centre.
3303
+ # Tries the canonical #px-captcha / [class*="px-captcha"] hooks first, then any
3304
+ # element whose visible text matches "press & hold" / "press and hold" / the
3305
+ # Turkish "basili tut". Returns JSON: {found, cx, cy, w, h, how} or {found:false}.
3306
+ PRESS_HOLD_FIND_JS = r"""
3307
+ (function(userSel) {
3308
+ try {
3309
+ function centre(el, how) {
3310
+ if (!el) return null;
3311
+ try { el.scrollIntoView({behavior: 'instant', block: 'center'}); } catch (e) {}
3312
+ var r = el.getBoundingClientRect();
3313
+ if (r.width < 1 || r.height < 1) return null;
3314
+ return JSON.stringify({
3315
+ found: true,
3316
+ cx: Math.round(r.left + r.width / 2),
3317
+ cy: Math.round(r.top + r.height / 2),
3318
+ w: Math.round(r.width), h: Math.round(r.height), how: how
3319
+ });
3320
+ }
3321
+ if (userSel) {
3322
+ var u = document.querySelector(userSel);
3323
+ var cu = centre(u, 'user-selector');
3324
+ if (cu) return cu;
3325
+ }
3326
+ // Canonical PerimeterX hooks.
3327
+ var px = document.querySelector('#px-captcha, [class*="px-captcha"]');
3328
+ if (px) {
3329
+ // The visible hold target is often an inner button/div; prefer a child
3330
+ // with real size, else the container itself.
3331
+ var inner = px.querySelector('div[role="button"], button, [tabindex], div');
3332
+ var ci = centre(inner, 'px-inner');
3333
+ if (ci) return ci;
3334
+ var cc = centre(px, 'px-container');
3335
+ if (cc) return cc;
3336
+ }
3337
+ // Text-based fallback: any small interactive element saying press & hold.
3338
+ var needles = ['press & hold', 'press and hold', 'press &amp; hold',
3339
+ 'basili tut', 'basılı tut', 'press hold'];
3340
+ var cands = document.querySelectorAll(
3341
+ 'button, [role="button"], div, span, a, p');
3342
+ for (var i = 0; i < cands.length; i++) {
3343
+ var el = cands[i];
3344
+ var t = (el.textContent || '').toLowerCase().trim();
3345
+ if (!t || t.length > 60) continue;
3346
+ for (var j = 0; j < needles.length; j++) {
3347
+ if (t.indexOf(needles[j]) >= 0) {
3348
+ var ct = centre(el, 'text-match');
3349
+ if (ct) return ct;
3350
+ }
3351
+ }
3352
+ }
3353
+ return JSON.stringify({ found: false });
3354
+ } catch (e) {
3355
+ return JSON.stringify({ found: false, error: String(e) });
3356
+ }
3357
+ })
3358
+ """
3359
+
3360
+
3361
+ def _press_hold_duration_ms():
3362
+ """Gaussian-randomized hold duration in ms. mu~4000, clamped 3000..7000.
3363
+
3364
+ PerimeterX rejects a fixed duration (machine-like). We draw from a Gaussian
3365
+ so each attempt looks individually human, then clamp to the band the widget
3366
+ actually accepts.
3367
+ """
3368
+ return int(_gauss(4000, 900, 3000, 7000))
3369
+
3370
+
3371
+ async def _press_hold_gesture(ws_url, x, y, hold_ms):
3372
+ """Emit one real press->hold(jitter)->release gesture at (x, y) over CDP.
3373
+
3374
+ 1. Bezier-humanized mouse move to the target (reuses _humanize_mouse_move).
3375
+ 2. Input.dispatchMouseEvent mousePressed (button:left) at the target.
3376
+ 3. Hold loop: every 100-200ms emit a mouseMoved with +/-1-2px jitter WHILE the
3377
+ left button stays pressed -- this is the natural hand-tremor PerimeterX
3378
+ inspects. Total loop time == hold_ms.
3379
+ 4. Input.dispatchMouseEvent mouseReleased.
3380
+ Returns the number of micro-jitter moves dispatched during the hold.
3381
+ """
3382
+ import random as _r
3383
+ r = _r.Random(int(_ENTROPY_SEED)) if _ENTROPY_SEED else _r.Random()
3384
+ # Land the cursor on the target with a human path + small final offset.
3385
+ await _humanize_mouse_move(ws_url, x, y)
3386
+ await asyncio.sleep(r.uniform(0.05, 0.18))
3387
+ # Press and hold the left button at the target.
3388
+ await cdp_send(ws_url, [(981, "Input.dispatchMouseEvent", {
3389
+ "type": "mousePressed", "x": x, "y": y,
3390
+ "button": "left", "buttons": 1, "clickCount": 1})])
3391
+ jitters = 0
3392
+ elapsed = 0.0
3393
+ cur_x, cur_y = x, y
3394
+ hold_s = hold_ms / 1000.0
3395
+ while elapsed < hold_s:
3396
+ step = r.uniform(0.10, 0.20)
3397
+ # Don't overshoot the requested hold window.
3398
+ if elapsed + step > hold_s:
3399
+ step = max(0.0, hold_s - elapsed)
3400
+ await asyncio.sleep(step)
3401
+ elapsed += step
3402
+ if elapsed >= hold_s:
3403
+ break
3404
+ # +/-1-2px tremor around the press point, button still held (buttons:1).
3405
+ cur_x = x + r.randint(-2, 2)
3406
+ cur_y = y + r.randint(-2, 2)
3407
+ await cdp_send(ws_url, [(982, "Input.dispatchMouseEvent", {
3408
+ "type": "mouseMoved", "x": cur_x, "y": cur_y,
3409
+ "button": "left", "buttons": 1, "modifiers": 0})])
3410
+ jitters += 1
3411
+ # Release at (near) the press point.
3412
+ await cdp_send(ws_url, [(983, "Input.dispatchMouseEvent", {
3413
+ "type": "mouseReleased", "x": cur_x, "y": cur_y,
3414
+ "button": "left", "buttons": 0, "clickCount": 1})])
3415
+ return jitters
3416
+
3417
+
3418
+ async def _solve_press_and_hold(ws_url, target_sel=None):
3419
+ """Solve a PerimeterX/HUMAN "Press & Hold" challenge with a humanized gesture.
3420
+
3421
+ Finds the hold target (#px-captcha / [class*="px-captcha"] / press-and-hold
3422
+ text, or an explicit target_sel), then performs press->hold(with micro-tremor)
3423
+ ->release. Verifies via _detect_captcha; retries once with a different hold
3424
+ duration if the challenge is still present.
3425
+
3426
+ Returns: {"solved": bool, "method": "press_and_hold", "hold_ms": N,
3427
+ "attempts": N, ...}. Never raises.
3428
+ """
3429
+ last_hold = 0
3430
+ attempts = 0
3431
+ max_attempts = 2
3432
+ for attempts in range(1, max_attempts + 1):
3433
+ # (Re)locate the target each attempt -- the widget may re-render.
3434
+ try:
3435
+ find_expr = f"({PRESS_HOLD_FIND_JS})({json.dumps(target_sel) if target_sel else 'null'})"
3436
+ res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
3437
+ "expression": find_expr, "returnByValue": True})], timeout=8)
3438
+ raw = res.get(1, {}).get("result", {}).get("value")
3439
+ loc = json.loads(raw) if raw else {"found": False}
3440
+ except Exception as e:
3441
+ return {"solved": False, "method": "press_and_hold",
3442
+ "hold_ms": last_hold, "attempts": attempts,
3443
+ "error": f"locate_failed:{str(e)[:80]}"}
3444
+ if not loc.get("found"):
3445
+ return {"solved": False, "method": "press_and_hold",
3446
+ "hold_ms": last_hold, "attempts": attempts,
3447
+ "error": "target_not_found"}
3448
+
3449
+ hold_ms = _press_hold_duration_ms()
3450
+ last_hold = hold_ms
3451
+ try:
3452
+ jitters = await _press_hold_gesture(
3453
+ ws_url, int(loc["cx"]), int(loc["cy"]), hold_ms)
3454
+ except Exception as e:
3455
+ return {"solved": False, "method": "press_and_hold",
3456
+ "hold_ms": hold_ms, "attempts": attempts,
3457
+ "error": f"gesture_failed:{str(e)[:80]}"}
3458
+
3459
+ # Settle, then check whether the challenge cleared.
3460
+ import random as _r
3461
+ await asyncio.sleep((_r.Random().uniform(500, 1500)) / 1000.0)
3462
+ info = await _detect_captcha(ws_url)
3463
+ still_px = 'perimeterx' in (info.get('types') or [])
3464
+ if not still_px:
3465
+ return {"solved": True, "method": "press_and_hold",
3466
+ "hold_ms": hold_ms, "attempts": attempts,
3467
+ "jitter_moves": jitters}
3468
+ # else: loop and try once more with a fresh (re-drawn) hold duration.
3469
+
3470
+ return {"solved": False, "method": "press_and_hold",
3471
+ "hold_ms": last_hold, "attempts": attempts,
3472
+ "error": "still_present_after_retry"}
3473
+
3474
+
2776
3475
  async def _humanize_type(ws_url, text):
2777
3476
  """Type text with Gaussian inter-key and dwell delays via CDP key events."""
2778
3477
  import random as _r
@@ -3873,72 +4572,301 @@ async def cmd_debug(url=None):
3873
4572
  print(f"Debug complete: {url}")
3874
4573
 
3875
4574
 
3876
- async def cmd_close():
3877
- ws, page = get_page_ws()
3878
- r = await cdp_send(ws, [(1, "Page.close", {})])
3879
- print("Tab closed")
4575
+ # ─── Owned-Tab Tracking ───
4576
+ # cdpilot records every target_id it opens so that `close` can distinguish its
4577
+ # own tabs from tabs the user opened manually in the same isolated profile.
4578
+ # Stored as a JSON list in the project profile dir; survives across CLI
4579
+ # invocations (each `go`/`new-tab` is a separate process).
3880
4580
 
4581
+ def _load_owned_tabs():
4582
+ """Return the set of target_ids cdpilot opened in this project profile."""
4583
+ try:
4584
+ with open(OWNED_TABS_FILE) as f:
4585
+ data = json.load(f)
4586
+ return set(data.get("owned", []))
4587
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
4588
+ return set()
3881
4589
 
3882
- def cmd_session():
3883
- """Show current session info."""
3884
- sid = _get_session_id()
3885
- sessions = _load_sessions()
3886
- info = sessions.get(sid)
3887
- if info:
3888
- print(f"Session: {sid}")
3889
- print(f" Target ID: {info.get('target_id', '?')}")
3890
- print(f" Created: {info.get('created', '?')}")
3891
- # Check if target is still active
3892
- tabs = cdp_get("/json") or []
3893
- active = any(t.get("id") == info.get("target_id") for t in tabs)
3894
- print(f" Status: {'active' if active else 'gone (will be recreated)'}")
3895
- else:
3896
- print(f"Session: {sid}")
3897
- print(" No window assigned yet (will be created on first command)")
4590
+
4591
+ def _save_owned_tabs(owned):
4592
+ """Persist the owned target_id set."""
4593
+ try:
4594
+ os.makedirs(os.path.dirname(OWNED_TABS_FILE), exist_ok=True)
4595
+ with open(OWNED_TABS_FILE, "w") as f:
4596
+ json.dump({"owned": sorted(owned)}, f)
4597
+ except OSError:
4598
+ pass
3898
4599
 
3899
4600
 
3900
- def cmd_sessions():
3901
- """List all active sessions."""
3902
- sessions = _cleanup_stale_sessions()
3903
- if not sessions:
3904
- print("No active sessions.")
4601
+ def _mark_owned_tab(target_id):
4602
+ """Record a target_id as cdpilot-owned. No-op on falsy input."""
4603
+ if not target_id:
3905
4604
  return
3906
- tabs = cdp_get("/json") or []
3907
- active_ids = {t.get("id") for t in tabs}
3908
- current_sid = _get_session_id()
3909
- print(f"{'Session ID':<30} {'Target ID':<40} {'Status':<8} {'Created'}")
3910
- print("─" * 100)
3911
- for sid, info in sessions.items():
3912
- tid = info.get("target_id", "?")
3913
- created = info.get("created", "?")
3914
- active = "active" if tid in active_ids else "gone"
3915
- marker = " ← (current)" if sid == current_sid else ""
3916
- print(f"{sid:<30} {tid:<40} {active:<8} {created}{marker}")
3917
- print(f"\nTotal: {len(sessions)} sessions")
4605
+ owned = _load_owned_tabs()
4606
+ if target_id not in owned:
4607
+ owned.add(target_id)
4608
+ _save_owned_tabs(owned)
3918
4609
 
3919
4610
 
3920
- def cmd_session_close(session_id=None):
3921
- """Close a specific session window and remove its registry entry."""
3922
- sid = session_id or _get_session_id()
3923
- sessions = _load_sessions()
3924
- info = sessions.get(sid)
3925
- if not info:
3926
- print(f"Session not found: {sid}")
3927
- return
4611
+ def _is_chrome_internal_url(url):
4612
+ """True for blank/new-tab/internal pages that don't count as 'real' tabs."""
4613
+ if not url:
4614
+ return True
4615
+ u = url.strip().lower()
4616
+ return (u in ("about:blank", "about:newtab", "")
4617
+ or u.startswith("chrome://")
4618
+ or u.startswith("edge://")
4619
+ or u.startswith("chrome-extension://")
4620
+ or u.startswith("devtools://")
4621
+ or u.startswith("brave://")
4622
+ or u.startswith("vivaldi://"))
4623
+
4624
+
4625
+ def _close_target(target_id):
4626
+ """Close one CDP target. Returns True when the browser accepted it."""
4627
+ try:
4628
+ urllib.request.urlopen(f"{CDP_BASE}/json/close/{target_id}", timeout=2)
4629
+ return True
4630
+ except Exception:
4631
+ return False
3928
4632
 
3929
- target_id = info.get("target_id")
3930
- if target_id:
3931
- # Close the tab
3932
- tabs = cdp_get("/json") or []
3933
- for t in tabs:
3934
- if t.get("id") == target_id and "webSocketDebuggerUrl" in t:
3935
- try:
3936
- import websockets
3937
- async def _close():
3938
- async with websockets.connect(
3939
- t["webSocketDebuggerUrl"],
3940
- max_size=10*1024*1024
3941
- ) as ws:
4633
+
4634
+ def _reap_tabs(max_tabs=1, include_real=False, dry_run=False):
4635
+ """Enforce a tab budget on the connected browser.
4636
+
4637
+ A long-lived browser accumulates abandoned tabs: every client that opens a
4638
+ target and exits without closing it leaks one, and nothing collects them.
4639
+ Measured on srv21 2026-08-30 — 513 pages, 506 of them about:blank, 12.7 GB
4640
+ across 529 renderer processes, growing ~6/hour for three days on a box with
4641
+ 47 GB total.
4642
+
4643
+ Blank tabs go first: they hold nothing a caller could still want. Pages with
4644
+ a real URL are never touched unless include_real is set, because on a shared
4645
+ browser they may belong to somebody else. The session's own tab and the last
4646
+ remaining page always survive.
4647
+ """
4648
+ tabs = cdp_get("/json", no_cache=True)
4649
+ if not tabs:
4650
+ return {"closed": [], "kept": 0, "reason": "no CDP connection"}
4651
+ pages = [t for t in tabs if t.get("type") == "page"]
4652
+ if len(pages) <= 1:
4653
+ return {"closed": [], "kept": len(pages), "reason": "nothing to reap"}
4654
+
4655
+ session_id = _get_session_window_target_id()
4656
+ real, blank = [], []
4657
+ for t in pages:
4658
+ (blank if _is_chrome_internal_url(t.get("url")) else real).append(t)
4659
+
4660
+ keep_ids = []
4661
+ if session_id and any(t.get("id") == session_id for t in pages):
4662
+ keep_ids.append(session_id)
4663
+ if not include_real:
4664
+ keep_ids += [t["id"] for t in real if t["id"] not in keep_ids]
4665
+ for t in blank:
4666
+ if len(keep_ids) >= max_tabs:
4667
+ break
4668
+ if t["id"] not in keep_ids:
4669
+ keep_ids.append(t["id"])
4670
+ if not keep_ids: # never leave the browser with zero pages
4671
+ keep_ids.append(pages[0]["id"])
4672
+
4673
+ doomed = [t for t in pages if t["id"] not in keep_ids]
4674
+ closed = []
4675
+ for t in doomed:
4676
+ if dry_run or _close_target(t["id"]):
4677
+ closed.append(t["id"])
4678
+ if closed and not dry_run:
4679
+ cdp_cache_invalidate()
4680
+ owned = _load_owned_tabs()
4681
+ if owned & set(closed):
4682
+ _save_owned_tabs(owned - set(closed))
4683
+ return {"closed": closed, "kept": len(keep_ids),
4684
+ "blank": len(blank), "real": len(real), "dry_run": dry_run}
4685
+
4686
+
4687
+ def _enforce_tab_budget():
4688
+ """Opt-in post-command reaping via CDPILOT_MAX_TABS.
4689
+
4690
+ Off unless the variable is set: silently closing tabs would be hostile on a
4691
+ desktop where the browser is also the user's. On an unattended server it is
4692
+ the difference between one tab and five hundred.
4693
+ """
4694
+ raw = os.environ.get("CDPILOT_MAX_TABS", "").strip()
4695
+ if not raw.isdigit() or int(raw) < 1:
4696
+ return
4697
+ try:
4698
+ result = _reap_tabs(max_tabs=int(raw))
4699
+ except Exception:
4700
+ return
4701
+ if result.get("closed"):
4702
+ print(f"[cdpilot] tab budget: closed {len(result['closed'])} idle tab(s)",
4703
+ file=sys.stderr)
4704
+
4705
+
4706
+ async def _browser_close_graceful():
4707
+ """Ask the browser to shut down gracefully via the CDP Browser.close
4708
+ command (cleanest cross-platform path — flushes state, no orphaned procs).
4709
+ Returns True if the command was acknowledged. Falls back to a SIGTERM-based
4710
+ process stop (never kill -9) when the WebSocket path fails."""
4711
+ try:
4712
+ browser_ws = await _get_browser_ws()
4713
+ if browser_ws:
4714
+ await cdp_send(browser_ws, [(1, "Browser.close", {})], timeout=5)
4715
+ return True
4716
+ except SystemExit:
4717
+ raise
4718
+ except Exception:
4719
+ pass
4720
+ # Fallback: graceful OS-level termination (SIGTERM on POSIX, taskkill on
4721
+ # Windows). kill -9 is intentionally NOT used here — last resort only.
4722
+ return _stop_browser_on_port(CDP_PORT)
4723
+
4724
+
4725
+ async def cmd_close(force_browser=False, keep_browser=False):
4726
+ """Smart close: shut down cdpilot's own tabs, then close the whole browser
4727
+ if no user tabs remain.
4728
+
4729
+ Behavior:
4730
+ 1. Close every cdpilot-owned tab (CDP Target.closeTarget).
4731
+ 2. Inspect the remaining page targets:
4732
+ - 0 real tabs left (only about:blank / chrome:// / new-tab) ->
4733
+ close the browser application gracefully (Browser.close, SIGTERM
4734
+ fallback — never kill -9).
4735
+ - one or more user tabs remain -> leave the browser open; only the
4736
+ owned tabs were closed.
4737
+
4738
+ Flags:
4739
+ keep_browser — never close the browser, only the owned tabs.
4740
+ force_browser — close the browser even if user tabs remain.
4741
+ """
4742
+ if not cdp_get("/json/version"):
4743
+ print("No browser running.")
4744
+ return
4745
+
4746
+ tabs = get_tabs()
4747
+ pages = [t for t in tabs if t.get("type") == "page"]
4748
+ owned = _load_owned_tabs()
4749
+
4750
+ # Make sure the current session window counts as owned even if it predates
4751
+ # tracking (legacy session created before this feature shipped).
4752
+ session_target = _get_session_window_target_id()
4753
+ if session_target:
4754
+ owned.add(session_target)
4755
+
4756
+ browser_ws = await _get_browser_ws()
4757
+ closed = 0
4758
+ for p in pages:
4759
+ tid = p.get("id")
4760
+ if tid and tid in owned:
4761
+ try:
4762
+ await cdp_send(browser_ws, [(1, "Target.closeTarget",
4763
+ {"targetId": tid})], timeout=5)
4764
+ closed += 1
4765
+ except Exception:
4766
+ pass
4767
+
4768
+ # Owned set is consumed — clear tracking + the CWD session window pointer so
4769
+ # a later command starts clean.
4770
+ _save_owned_tabs(set())
4771
+ if session_target:
4772
+ sessions = _load_sessions()
4773
+ sid = _get_session_id()
4774
+ sessions.pop(sid, None)
4775
+ _save_sessions(sessions)
4776
+ cdp_cache_invalidate()
4777
+
4778
+ print(f"Closed {closed} cdpilot tab(s).")
4779
+
4780
+ # Re-read remaining targets after closing owned tabs.
4781
+ remaining = get_tabs()
4782
+ remaining_pages = [t for t in remaining if t.get("type") == "page"]
4783
+ user_pages = [p for p in remaining_pages
4784
+ if not _is_chrome_internal_url(p.get("url"))]
4785
+
4786
+ if keep_browser:
4787
+ if user_pages:
4788
+ print(f"Browser left open ({len(user_pages)} user tab(s)).")
4789
+ else:
4790
+ print("Browser left open (--keep).")
4791
+ return
4792
+
4793
+ if user_pages and not force_browser:
4794
+ print(f"Browser left open — {len(user_pages)} user tab(s) still present.")
4795
+ return
4796
+
4797
+ # No user tabs remain (or --force) -> close the browser gracefully.
4798
+ if await _browser_close_graceful():
4799
+ print(f"Browser closed gracefully (port {CDP_PORT}).")
4800
+ if PROJECT_ID:
4801
+ registry = _load_registry()
4802
+ if PROJECT_ID in registry:
4803
+ registry[PROJECT_ID]["status"] = "stopped"
4804
+ registry[PROJECT_ID]["pid"] = None
4805
+ _save_registry(registry)
4806
+ else:
4807
+ print("Browser close failed.", file=sys.stderr)
4808
+
4809
+
4810
+ def cmd_session():
4811
+ """Show current session info."""
4812
+ sid = _get_session_id()
4813
+ sessions = _load_sessions()
4814
+ info = sessions.get(sid)
4815
+ if info:
4816
+ print(f"Session: {sid}")
4817
+ print(f" Target ID: {info.get('target_id', '?')}")
4818
+ print(f" Created: {info.get('created', '?')}")
4819
+ # Check if target is still active
4820
+ tabs = cdp_get("/json") or []
4821
+ active = any(t.get("id") == info.get("target_id") for t in tabs)
4822
+ print(f" Status: {'active' if active else 'gone (will be recreated)'}")
4823
+ else:
4824
+ print(f"Session: {sid}")
4825
+ print(" No window assigned yet (will be created on first command)")
4826
+
4827
+
4828
+ def cmd_sessions():
4829
+ """List all active sessions."""
4830
+ sessions = _cleanup_stale_sessions()
4831
+ if not sessions:
4832
+ print("No active sessions.")
4833
+ return
4834
+ tabs = cdp_get("/json") or []
4835
+ active_ids = {t.get("id") for t in tabs}
4836
+ current_sid = _get_session_id()
4837
+ print(f"{'Session ID':<30} {'Target ID':<40} {'Status':<8} {'Created'}")
4838
+ print("─" * 100)
4839
+ for sid, info in sessions.items():
4840
+ tid = info.get("target_id", "?")
4841
+ created = info.get("created", "?")
4842
+ active = "active" if tid in active_ids else "gone"
4843
+ marker = " ← (current)" if sid == current_sid else ""
4844
+ print(f"{sid:<30} {tid:<40} {active:<8} {created}{marker}")
4845
+ print(f"\nTotal: {len(sessions)} sessions")
4846
+
4847
+
4848
+ def cmd_session_close(session_id=None):
4849
+ """Close a specific session window and remove its registry entry."""
4850
+ sid = session_id or _get_session_id()
4851
+ sessions = _load_sessions()
4852
+ info = sessions.get(sid)
4853
+ if not info:
4854
+ print(f"Session not found: {sid}")
4855
+ return
4856
+
4857
+ target_id = info.get("target_id")
4858
+ if target_id:
4859
+ # Close the tab
4860
+ tabs = cdp_get("/json") or []
4861
+ for t in tabs:
4862
+ if t.get("id") == target_id and "webSocketDebuggerUrl" in t:
4863
+ try:
4864
+ import websockets
4865
+ async def _close():
4866
+ async with websockets.connect(
4867
+ t["webSocketDebuggerUrl"],
4868
+ max_size=10*1024*1024
4869
+ ) as ws:
3942
4870
  await ws.send(json.dumps({
3943
4871
  "id": 1, "method": "Page.close", "params": {}
3944
4872
  }))
@@ -4300,6 +5228,7 @@ async def cmd_new_tab(url='about:blank'):
4300
5228
  data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=safe_chars)}')
4301
5229
  cdp_cache_invalidate()
4302
5230
  if data:
5231
+ _mark_owned_tab(data.get("id"))
4303
5232
  print(f'New tab opened: {data.get("url", url)}')
4304
5233
  print(f' ID: {data.get("id", "?")}')
4305
5234
  else:
@@ -4870,6 +5799,14 @@ def cmd_stealth(state=None):
4870
5799
  os.makedirs(os.path.dirname(STEALTH_CONFIG_FILE), exist_ok=True)
4871
5800
  with open(STEALTH_CONFIG_FILE, 'w') as f:
4872
5801
  json.dump({'stealth': enabled}, f)
5802
+ # Backward-compat bridge to the three-tier model: keep mode.json coherent
5803
+ # with the legacy toggle so `stealth on` -> 'undetected' (full patch) and
5804
+ # `stealth off` -> 'regular'. Without this, an existing mode.json would
5805
+ # silently override the toggle (get_mode_config reads mode.json first).
5806
+ try:
5807
+ set_mode_config('undetected' if enabled else 'regular')
5808
+ except Exception:
5809
+ pass
4873
5810
  print(f'Stealth: {"on" if enabled else "off"}')
4874
5811
  print('Effect applies on next navigation (`cdpilot go <url>`).')
4875
5812
 
@@ -5128,18 +6065,25 @@ async def _assert_host(ws_url, expected_host):
5128
6065
 
5129
6066
 
5130
6067
  def get_adaptive_config():
5131
- """Return {'enabled': bool, 'stealth_hosts': [hostname,...]}."""
6068
+ """Return adaptive config with all learned-state keys carried through.
6069
+
6070
+ Keys: enabled, stealth_hosts, entropy_hosts, host_tiers. The latter two
6071
+ MUST be preserved here because callers write the whole dict back via
6072
+ _atomic_write_json — dropping a key would silently erase learned state.
6073
+ """
5132
6074
  if not os.path.exists(ADAPTIVE_CONFIG_FILE):
5133
- return {'enabled': False, 'stealth_hosts': []}
6075
+ return {'enabled': False, 'stealth_hosts': [], 'entropy_hosts': {}, 'host_tiers': {}}
5134
6076
  try:
5135
6077
  with open(ADAPTIVE_CONFIG_FILE) as f:
5136
6078
  data = json.load(f)
5137
6079
  return {
5138
6080
  'enabled': bool(data.get('enabled', False)),
5139
6081
  'stealth_hosts': list(data.get('stealth_hosts', [])),
6082
+ 'entropy_hosts': dict(data.get('entropy_hosts', {})),
6083
+ 'host_tiers': dict(data.get('host_tiers', {})),
5140
6084
  }
5141
6085
  except (OSError, ValueError):
5142
- return {'enabled': False, 'stealth_hosts': []}
6086
+ return {'enabled': False, 'stealth_hosts': [], 'entropy_hosts': {}, 'host_tiers': {}}
5143
6087
 
5144
6088
 
5145
6089
  def _adaptive_remember_host(hostname):
@@ -5151,6 +6095,48 @@ def _adaptive_remember_host(hostname):
5151
6095
  _atomic_write_json(ADAPTIVE_CONFIG_FILE, cfg)
5152
6096
 
5153
6097
 
6098
+ def _escalate_tier(tier):
6099
+ """Return the next-stronger tier. undetected is the ceiling."""
6100
+ try:
6101
+ i = MODE_TIERS.index(tier)
6102
+ except ValueError:
6103
+ i = 0
6104
+ return MODE_TIERS[min(i + 1, len(MODE_TIERS) - 1)]
6105
+
6106
+
6107
+ def _adaptive_remember_host_tier(hostname, tier):
6108
+ """Record the tier a host needs in adaptive.json under 'host_tiers'.
6109
+
6110
+ Monotonic: only ever ratchets UP (regular -> stealth -> undetected),
6111
+ never down — mirrors the never-auto-demote policy of the stealth_hosts
6112
+ list so a single false-negative CAPTCHA detection can't downgrade a host.
6113
+ """
6114
+ if tier not in MODE_TIERS:
6115
+ return
6116
+ cfg = get_adaptive_config()
6117
+ host_tiers = cfg.get('host_tiers', {})
6118
+ prev = host_tiers.get(hostname)
6119
+ if prev in MODE_TIERS and MODE_TIERS.index(prev) >= MODE_TIERS.index(tier):
6120
+ return # already at or above this tier, skip write
6121
+ host_tiers[hostname] = tier
6122
+ cfg['host_tiers'] = host_tiers
6123
+ _atomic_write_json(ADAPTIVE_CONFIG_FILE, cfg)
6124
+
6125
+
6126
+ def _adaptive_host_tier(url):
6127
+ """Return the learned tier for a URL's host, or None if none/disabled."""
6128
+ cfg = get_adaptive_config()
6129
+ if not cfg.get('enabled'):
6130
+ return None
6131
+ try:
6132
+ from urllib.parse import urlparse
6133
+ host = (urlparse(url).hostname or '').lower()
6134
+ except Exception:
6135
+ return None
6136
+ tier = cfg.get('host_tiers', {}).get(host)
6137
+ return tier if tier in MODE_TIERS else None
6138
+
6139
+
5154
6140
  def _adaptive_host_requires_stealth(url):
5155
6141
  """True if the URL's hostname is in the adaptive stealth list."""
5156
6142
  cfg = get_adaptive_config()
@@ -5281,6 +6267,140 @@ async def _detect_captcha(ws_url):
5281
6267
  return {"detected": False, "error": str(e)[:120]}
5282
6268
 
5283
6269
 
6270
+ # ─── Progressive-Resilience Escalation Ladder ───
6271
+ # Friction levels ordered low -> high. soft_captcha is detected by the existing
6272
+ # _detect_captcha (9 provider types); the other rungs come from
6273
+ # FRICTION_DETECT_JS. _detect_friction merges both and reports the highest.
6274
+ FRICTION_LEVELS = (
6275
+ 'none', 'rate_limited', 'soft_captcha', 'login_wall', 'otp_sms', 'hard_block',
6276
+ )
6277
+
6278
+
6279
+ def _friction_backoff_enabled():
6280
+ """Whether cmd_go should auto-backoff on rate_limited. Default on."""
6281
+ return os.environ.get('CDPILOT_FRICTION_BACKOFF', 'on').lower() not in ('off', '0', 'false', 'no')
6282
+
6283
+
6284
+ def _friction_max_retry():
6285
+ """Max rate-limit backoff retries in cmd_go. Default 2, clamped 0..5."""
6286
+ try:
6287
+ n = int(os.environ.get('CDPILOT_FRICTION_MAX_RETRY', '2'))
6288
+ except (ValueError, TypeError):
6289
+ n = 2
6290
+ return max(0, min(n, 5))
6291
+
6292
+
6293
+ def _friction_backoff_seconds(attempt):
6294
+ """Exponential backoff with jitter: 2^attempt seconds, capped at 60s.
6295
+
6296
+ attempt is 0-based (first retry -> 2s base). Adds up to 25% jitter so
6297
+ concurrent workers don't synchronize their retries.
6298
+ """
6299
+ import random as _r
6300
+ base = min(2 ** (attempt + 1), 60)
6301
+ jitter = _r.uniform(0, base * 0.25)
6302
+ return round(min(base + jitter, 60), 2)
6303
+
6304
+
6305
+ def _friction_action(level):
6306
+ """Map a friction level to the response policy cmd_go should apply.
6307
+
6308
+ ETHICS/SAFETY: login_wall, otp_sms and hard_block are NEVER bypassed
6309
+ autonomously — they hand off to the human. Only rate_limited is auto-handled
6310
+ (backoff + retry); soft_captcha defers to the existing captcha flow.
6311
+
6312
+ Returns a dict: {action, level, autonomous: bool, message}.
6313
+ """
6314
+ if level == 'rate_limited':
6315
+ return {
6316
+ 'action': 'backoff', 'level': level, 'autonomous': True,
6317
+ 'message': 'Rate limited — exponential backoff then retry.',
6318
+ }
6319
+ if level == 'soft_captcha':
6320
+ return {
6321
+ 'action': 'captcha', 'level': level, 'autonomous': True,
6322
+ 'message': 'CAPTCHA — defer to detect/wait/solver flow.',
6323
+ }
6324
+ if level == 'login_wall':
6325
+ return {
6326
+ 'action': 'human_login_required', 'level': level, 'autonomous': False,
6327
+ 'message': '🔐 Login gerekli — kullanıcı girişi bekleniyor (otomatik giriş YOK).',
6328
+ }
6329
+ if level == 'otp_sms':
6330
+ return {
6331
+ 'action': 'human_otp_required', 'level': level, 'autonomous': False,
6332
+ 'message': '📱 SMS/OTP doğrulama gerekli — kullanıcı çözmeli (otomatik çözüm YOK).',
6333
+ }
6334
+ if level == 'hard_block':
6335
+ return {
6336
+ 'action': 'hard_blocked', 'level': level, 'autonomous': False,
6337
+ 'message': '🚫 Sert engelleme — geri çekil ve bekle.', 'backoff_suggested': True,
6338
+ }
6339
+ return {'action': 'proceed', 'level': 'none', 'autonomous': True, 'message': 'No friction.'}
6340
+
6341
+
6342
+ async def _detect_friction(ws_url):
6343
+ """Detect the highest anti-bot friction rung on the active page.
6344
+
6345
+ Runs FRICTION_DETECT_JS (rate-limit / login-wall / OTP / hard-block) and
6346
+ merges in the existing _detect_captcha result (soft_captcha). Returns the
6347
+ HIGHEST rung present per FRICTION_LEVELS ordering.
6348
+
6349
+ Returns: {"level": str, "signals": [...], "detail": str, "captcha"?: {...}}
6350
+ Never raises — failures degrade to {"level": "none", ...}.
6351
+ """
6352
+ friction = {"level": "none", "signals": [], "detail": ""}
6353
+ try:
6354
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
6355
+ "expression": FRICTION_DETECT_JS,
6356
+ "returnByValue": True,
6357
+ })], timeout=5)
6358
+ raw = r.get(1, {}).get("result", {}).get("value")
6359
+ if raw:
6360
+ try:
6361
+ parsed = json.loads(raw)
6362
+ if isinstance(parsed, dict) and parsed.get("level") in FRICTION_LEVELS:
6363
+ friction = parsed
6364
+ except (ValueError, TypeError):
6365
+ pass
6366
+ except Exception as e:
6367
+ friction = {"level": "none", "signals": [], "detail": "", "error": str(e)[:120]}
6368
+
6369
+ # Merge soft_captcha from the dedicated captcha detector.
6370
+ try:
6371
+ cap = await _detect_captcha(ws_url)
6372
+ except Exception:
6373
+ cap = {"detected": False}
6374
+ if cap.get("detected"):
6375
+ cap_level = 'soft_captcha'
6376
+ # Keep whichever rung is higher in the ladder.
6377
+ cur_idx = FRICTION_LEVELS.index(friction.get('level', 'none')) if friction.get('level') in FRICTION_LEVELS else 0
6378
+ cap_idx = FRICTION_LEVELS.index(cap_level)
6379
+ if cap_idx > cur_idx:
6380
+ sigs = list(friction.get('signals', []))
6381
+ sigs.append('captcha:' + (",".join(cap.get('types', [])) or 'unknown'))
6382
+ friction = {
6383
+ 'level': cap_level, 'signals': sigs,
6384
+ 'detail': 'CAPTCHA challenge present (' + (",".join(cap.get('types', [])) or 'unknown') + ').',
6385
+ }
6386
+ friction['captcha'] = {'types': cap.get('types', []), 'detected': True}
6387
+ return friction
6388
+
6389
+
6390
+ async def cmd_friction():
6391
+ """One-shot friction-level diagnostic on the active page. Prints JSON.
6392
+
6393
+ Reports the highest escalation rung (none/rate_limited/soft_captcha/
6394
+ login_wall/otp_sms/hard_block) plus the recommended response policy.
6395
+ Read-only — never attempts to bypass anything.
6396
+ """
6397
+ ws, _ = get_page_ws()
6398
+ info = await _detect_friction(ws)
6399
+ action = _friction_action(info.get('level', 'none'))
6400
+ info['recommended'] = action
6401
+ print(json.dumps(info, ensure_ascii=False))
6402
+
6403
+
5284
6404
  async def cmd_captcha_check():
5285
6405
  """One-shot CAPTCHA detection on the active page. Prints JSON.
5286
6406
 
@@ -6089,59 +7209,444 @@ async def cmd_captcha_dispatch(args: list) -> None:
6089
7209
  sys.exit(1)
6090
7210
 
6091
7211
 
6092
- # ─── End Captcha Solver Plugin ───────────────────────────────────────────
7212
+ # ─── Amazon classic image CAPTCHA (local OCR + BYOK image solvers) ────────
7213
+ # amazon.com rate-limit page: "Enter the characters you see below". The image
7214
+ # is a 6-char distorted glyph string at a fixed URL pattern. The optional
7215
+ # `amazoncaptcha` PyPI lib (pure-Python + Pillow, MIT) OCRs it offline with
7216
+ # ~90% accuracy. It is NOT a mandatory dependency — imported lazily.
7217
+ # BYOK fallback: CapSolver / 2Captcha image-to-text endpoints (HTTP only).
6093
7218
 
6094
7219
 
6095
- def _stop_browser_on_port(port):
6096
- """Stop the browser process listening on the given port."""
6097
- import signal
7220
+ async def _detect_amazon_captcha(ws_url: str) -> dict:
7221
+ """Return {'detected': bool, 'img_src': str, 'input_id': str} for Amazon classic CAPTCHA.
7222
+
7223
+ Probes the active page for the #captchacharacters input + captcha image.
7224
+ Never raises.
7225
+ """
7226
+ info = await _detect_captcha(ws_url)
7227
+ if not info.get('detected'):
7228
+ return {'detected': False}
7229
+ for d in info.get('details', []):
7230
+ if d.get('type') == 'amazon-classic':
7231
+ return {
7232
+ 'detected': True,
7233
+ 'img_src': d.get('img_src', ''),
7234
+ 'input_id': d.get('input_id', 'captchacharacters'),
7235
+ }
7236
+ return {'detected': False}
7237
+
7238
+
7239
+ async def _fetch_image_bytes(ws_url: str, img_src: str) -> bytes | None:
7240
+ """Download the captcha image bytes. Uses urllib (image URLs are public CDN)."""
7241
+ if not img_src:
7242
+ return None
6098
7243
  try:
6099
- result = subprocess.run(
6100
- ["lsof", "-ti", f":{port}"],
6101
- capture_output=True, text=True
6102
- )
6103
- pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
6104
- if pids:
6105
- for pid in pids:
6106
- os.kill(int(pid), signal.SIGTERM)
6107
- return True
6108
- else:
6109
- subprocess.run(
6110
- ["pkill", "-f", f"remote-debugging-port={port}"],
6111
- capture_output=True, text=True
6112
- )
6113
- return True
7244
+ return await _captcha_urlopen_async(img_src, timeout=15)
6114
7245
  except Exception:
6115
- return False
7246
+ return None
6116
7247
 
6117
7248
 
6118
- # ─── Browserbase-Compatible Local API ───
7249
+ def _solve_amazon_local(img_bytes: bytes) -> str | None:
7250
+ """OCR Amazon classic CAPTCHA with the OPTIONAL amazoncaptcha library.
6119
7251
 
6120
- DEFAULT_MAX_SESSIONS = int(os.environ.get('CDPILOT_MAX_SESSIONS', '10'))
6121
- _api_session_store: dict = {} # session_id -> session_dict
7252
+ Returns the solved string, or None if the library is unavailable or fails.
7253
+ Raises ImportError-as-signal via returning None; caller prints install hint.
7254
+ """
7255
+ try:
7256
+ from amazoncaptcha import AmazonCaptcha # optional dependency
7257
+ except ImportError:
7258
+ return None
7259
+ try:
7260
+ import io as _io
7261
+ cap = AmazonCaptcha(_io.BytesIO(img_bytes))
7262
+ solution = cap.solve()
7263
+ if solution and solution.lower() != 'not solved':
7264
+ return solution
7265
+ except Exception:
7266
+ return None
7267
+ return None
6122
7268
 
6123
7269
 
6124
- def _api_make_session_id() -> str:
6125
- """Generate sess_<8 hex chars> unique ID."""
6126
- return 'sess_' + secrets.token_hex(4)
7270
+ async def _solve_image_byok(img_bytes: bytes) -> dict:
7271
+ """Solve an image CAPTCHA via a BYOK provider (CapSolver / 2Captcha).
6127
7272
 
7273
+ Reads CAPSOLVER_API_KEY then TWOCAPTCHA_API_KEY from the environment.
7274
+ Only stdlib HTTP — no new dependency. Returns {'token': text} or {'error': ...}.
7275
+ """
7276
+ import base64 as _b64
7277
+ b64 = _b64.b64encode(img_bytes).decode('ascii')
6128
7278
 
6129
- def _api_create_session(opts: dict) -> dict:
6130
- """Launch browser for a new API session. Returns session dict."""
6131
- if len(_api_session_store) >= DEFAULT_MAX_SESSIONS:
6132
- raise ValueError("Maximum session limit reached")
7279
+ capsolver_key = os.environ.get('CAPSOLVER_API_KEY')
7280
+ if capsolver_key:
7281
+ try:
7282
+ payload = json.dumps({
7283
+ 'clientKey': capsolver_key,
7284
+ 'task': {'type': 'ImageToTextTask', 'body': b64},
7285
+ }).encode()
7286
+ raw = await _captcha_urlopen_async(
7287
+ 'https://api.capsolver.com/createTask', data=payload,
7288
+ headers={'Content-Type': 'application/json'}, timeout=60,
7289
+ )
7290
+ r = json.loads(raw)
7291
+ if r.get('errorId', 0) == 0:
7292
+ text = (r.get('solution') or {}).get('text', '')
7293
+ if text:
7294
+ return {'token': text, 'provider': 'capsolver'}
7295
+ return {'error': f"capsolver: {r.get('errorDescription', r)}"}
7296
+ except Exception as e:
7297
+ return {'error': f'capsolver: {e}'}
6133
7298
 
6134
- session_id = _api_make_session_id()
6135
- proj_id = f'api-{session_id}'
6136
- port = _allocate_port(proj_id)
6137
- created_at = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
7299
+ twocaptcha_key = os.environ.get('TWOCAPTCHA_API_KEY')
7300
+ if twocaptcha_key:
7301
+ import urllib.parse as _uparse
7302
+ try:
7303
+ body = _uparse.urlencode({
7304
+ 'key': twocaptcha_key, 'method': 'base64',
7305
+ 'body': b64, 'json': '1',
7306
+ }).encode()
7307
+ raw = await _captcha_urlopen_async('https://2captcha.com/in.php', data=body, timeout=30)
7308
+ resp = json.loads(raw)
7309
+ if resp.get('status') != 1:
7310
+ return {'error': f"2captcha submit: {resp.get('request', resp)}"}
7311
+ task_id = resp['request']
7312
+ poll = f"https://2captcha.com/res.php?key={twocaptcha_key}&action=get&id={task_id}&json=1"
7313
+ deadline = time.time() + CAPTCHA_SOLVE_TIMEOUT
7314
+ while time.time() < deadline:
7315
+ await asyncio.sleep(5)
7316
+ r2 = json.loads(await _captcha_urlopen_async(poll, timeout=15))
7317
+ if r2.get('status') == 1:
7318
+ return {'token': r2['request'], 'provider': '2captcha'}
7319
+ if r2.get('request') != 'CAPCHA_NOT_READY':
7320
+ return {'error': f"2captcha: {r2.get('request')}"}
7321
+ return {'error': '2captcha timeout'}
7322
+ except Exception as e:
7323
+ return {'error': f'2captcha: {e}'}
6138
7324
 
6139
- sess = {
6140
- 'id': session_id,
6141
- 'createdAt': created_at,
6142
- 'projectId': proj_id,
6143
- 'status': 'RUNNING',
6144
- 'connectUrl': '',
7325
+ return {'error': 'no_byok_key'}
7326
+
7327
+
7328
+ async def _fill_amazon_solution(ws_url: str, input_id: str, solution: str) -> bool:
7329
+ """Type the solution into the Amazon CAPTCHA input and submit. Returns True on success."""
7330
+ safe_id = json.dumps(input_id)
7331
+ safe_val = json.dumps(solution)
7332
+ js = f"""
7333
+ (function() {{
7334
+ try {{
7335
+ var el = document.getElementById({safe_id}) || document.querySelector('#captchacharacters');
7336
+ if (!el) return false;
7337
+ var setter = Object.getOwnPropertyDescriptor(
7338
+ window.HTMLInputElement.prototype, 'value').set;
7339
+ setter.call(el, {safe_val});
7340
+ el.dispatchEvent(new Event('input', {{bubbles: true}}));
7341
+ el.dispatchEvent(new Event('change', {{bubbles: true}}));
7342
+ var form = el.form || el.closest('form');
7343
+ if (form) {{ form.submit(); return true; }}
7344
+ var btn = document.querySelector('button[type=submit], input[type=submit]');
7345
+ if (btn) {{ btn.click(); return true; }}
7346
+ return true;
7347
+ }} catch(e) {{ return false; }}
7348
+ }})()
7349
+ """
7350
+ try:
7351
+ r = await cdp_send(ws_url, [(1, 'Runtime.evaluate', {
7352
+ 'expression': js, 'returnByValue': True,
7353
+ })], timeout=5)
7354
+ return bool(r.get(1, {}).get('result', {}).get('value', False))
7355
+ except Exception:
7356
+ return False
7357
+
7358
+
7359
+ async def cmd_press_hold(selector=None):
7360
+ """Solve a PerimeterX/HUMAN "Press & Hold" challenge (standalone, opt-in).
7361
+
7362
+ Performs a humanized press->hold(with micro-tremor)->release gesture on the
7363
+ hold target. If no selector is given, auto-locates the px-captcha widget
7364
+ (#px-captcha / [class*="px-captcha"] / "press & hold" text). Prints JSON;
7365
+ exit 0 = solved, 1 = not solved / target not found.
7366
+
7367
+ Usage:
7368
+ cdpilot press-hold # auto-find the px-captcha target
7369
+ cdpilot press-hold "#px-captcha button"
7370
+ """
7371
+ if not cdp_get("/json/version"):
7372
+ print(json.dumps({'solved': False, 'error': 'no_browser',
7373
+ 'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
7374
+ sys.exit(1)
7375
+ ws, _ = get_page_ws()
7376
+ res = await _solve_press_and_hold(ws, target_sel=selector)
7377
+ print(json.dumps(res, ensure_ascii=False))
7378
+ sys.exit(0 if res.get('solved') else 1)
7379
+
7380
+
7381
+ async def cmd_captcha_solve(provider=None):
7382
+ """Solve the CAPTCHA on the current page (opt-in).
7383
+
7384
+ Providers:
7385
+ amazon-local (default) — OCR Amazon classic image CAPTCHA with the optional
7386
+ `amazoncaptcha` lib (pip install amazoncaptcha).
7387
+ capsolver / 2captcha — BYOK image-to-text via CAPSOLVER_API_KEY /
7388
+ TWOCAPTCHA_API_KEY env vars (stdlib HTTP only).
7389
+
7390
+ Detects Amazon classic CAPTCHA (#captchacharacters + captcha image), solves,
7391
+ fills the input and submits. Prints JSON; exit 0 = solved, 3 = no captcha,
7392
+ 1 = error / unsolvable.
7393
+ """
7394
+ provider = (provider or 'amazon-local').lower()
7395
+ ws, _ = get_page_ws()
7396
+
7397
+ # PerimeterX / HUMAN "Press & Hold" is a behavioural (not token) challenge:
7398
+ # there is no provider to call, the only solution is a real humanized
7399
+ # press->hold->release gesture. Route it automatically whenever detected,
7400
+ # regardless of the requested provider, before the Amazon/BYOK path.
7401
+ pre = await _detect_captcha(ws)
7402
+ if 'perimeterx' in (pre.get('types') or []):
7403
+ res = await _solve_press_and_hold(ws)
7404
+ print(json.dumps(res, ensure_ascii=False))
7405
+ sys.exit(0 if res.get('solved') else 1)
7406
+
7407
+ amz = await _detect_amazon_captcha(ws)
7408
+ if not amz.get('detected'):
7409
+ # Not an Amazon classic captcha — report what (if anything) was found.
7410
+ info = await _detect_captcha(ws)
7411
+ print(json.dumps({
7412
+ 'solved': False, 'status': 'no_amazon_captcha',
7413
+ 'detected_types': info.get('types', []),
7414
+ }, ensure_ascii=False))
7415
+ sys.exit(3 if not info.get('detected') else 1)
7416
+
7417
+ img_src = amz.get('img_src', '')
7418
+ input_id = amz.get('input_id', 'captchacharacters')
7419
+ img_bytes = await _fetch_image_bytes(ws, img_src)
7420
+ if not img_bytes:
7421
+ print(json.dumps({'solved': False, 'error': 'image_fetch_failed', 'img_src': img_src}, ensure_ascii=False))
7422
+ sys.exit(1)
7423
+
7424
+ solution = None
7425
+ used_provider = None
7426
+
7427
+ if provider in ('amazon-local', 'amazon', 'local'):
7428
+ solution = _solve_amazon_local(img_bytes)
7429
+ used_provider = 'amazon-local'
7430
+ if solution is None:
7431
+ # Distinguish "lib missing" from "lib failed".
7432
+ try:
7433
+ import amazoncaptcha # noqa: F401
7434
+ print(json.dumps({
7435
+ 'solved': False, 'status': 'ocr_failed',
7436
+ 'provider': 'amazon-local',
7437
+ 'hint': 'OCR returned no result; retry or use --provider capsolver',
7438
+ }, ensure_ascii=False))
7439
+ except ImportError:
7440
+ print(json.dumps({
7441
+ 'solved': False, 'status': 'amazoncaptcha_not_installed',
7442
+ 'hint': 'amazoncaptcha not installed. Run: pip install amazoncaptcha (optional)',
7443
+ }, ensure_ascii=False))
7444
+ sys.exit(1)
7445
+ elif provider in ('capsolver', '2captcha', 'twocaptcha'):
7446
+ res = await _solve_image_byok(img_bytes)
7447
+ if 'error' in res:
7448
+ if res['error'] == 'no_byok_key':
7449
+ print(json.dumps({
7450
+ 'solved': False, 'status': 'no_byok_key',
7451
+ 'hint': 'Set CAPSOLVER_API_KEY or TWOCAPTCHA_API_KEY in the environment',
7452
+ }, ensure_ascii=False))
7453
+ else:
7454
+ print(json.dumps({'solved': False, 'error': res['error']}, ensure_ascii=False))
7455
+ sys.exit(1)
7456
+ solution = res.get('token')
7457
+ used_provider = res.get('provider', provider)
7458
+ else:
7459
+ print(json.dumps({'solved': False, 'error': f'unknown_provider:{provider}'}, ensure_ascii=False))
7460
+ sys.exit(1)
7461
+
7462
+ if not solution:
7463
+ print(json.dumps({'solved': False, 'error': 'empty_solution', 'provider': used_provider}, ensure_ascii=False))
7464
+ sys.exit(1)
7465
+
7466
+ filled = await _fill_amazon_solution(ws, input_id, solution)
7467
+ print(json.dumps({
7468
+ 'solved': True, 'solution': solution, 'provider': used_provider,
7469
+ 'submitted': filled,
7470
+ }, ensure_ascii=False))
7471
+ sys.exit(0)
7472
+
7473
+
7474
+ # ─── Profile warm-up (reCAPTCHA v3 score aging) ──────────────────────────
7475
+ # New browser profiles get a -0.5 reCAPTCHA v3 trust penalty. Browsing a few
7476
+ # popular, safe sites ages the cookie jar / history so the profile reads as
7477
+ # "established", lifting the v3 score. Opt-in, uses the already-open session.
7478
+
7479
+ WARM_SAFE_SITES = [
7480
+ 'https://en.wikipedia.org/wiki/Web_browser',
7481
+ 'https://github.com/',
7482
+ 'https://stackoverflow.com/',
7483
+ 'https://news.ycombinator.com/',
7484
+ 'https://www.bbc.com/news',
7485
+ 'https://www.reddit.com/',
7486
+ 'https://duckduckgo.com/',
7487
+ 'https://www.wikipedia.org/',
7488
+ ]
7489
+
7490
+
7491
+ async def cmd_profile_warm(minutes=None, sites=None):
7492
+ """Warm up the current profile by browsing safe popular sites (opt-in).
7493
+
7494
+ Ages cookies/history to boost reCAPTCHA v3 score. Uses the already-open
7495
+ browser session (does not launch). Default ~2 minutes over a hardcoded
7496
+ safe site list with randomized dwell + light scrolling between visits.
7497
+
7498
+ Args:
7499
+ minutes: budget in minutes (default 2, max 30). String from CLI.
7500
+ sites: optional comma-separated override list of URLs.
7501
+ """
7502
+ import random as _r
7503
+ try:
7504
+ budget_min = float(minutes) if minutes else 2.0
7505
+ except (ValueError, TypeError):
7506
+ budget_min = 2.0
7507
+ budget_min = max(0.5, min(budget_min, 30.0))
7508
+
7509
+ site_list = WARM_SAFE_SITES
7510
+ if sites:
7511
+ if isinstance(sites, str):
7512
+ site_list = [s.strip() for s in sites.split(',') if s.strip()]
7513
+ else:
7514
+ site_list = list(sites)
7515
+ if not site_list:
7516
+ site_list = WARM_SAFE_SITES
7517
+
7518
+ if not cdp_get("/json/version"):
7519
+ print(json.dumps({'error': 'no_browser', 'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
7520
+ sys.exit(1)
7521
+
7522
+ ws, _ = get_page_ws()
7523
+ deadline = time.time() + budget_min * 60.0
7524
+ rnd = _r.Random()
7525
+ visited = 0
7526
+ total = len(site_list)
7527
+
7528
+ for idx, url in enumerate(site_list, 1):
7529
+ if time.time() >= deadline:
7530
+ break
7531
+ try:
7532
+ await navigate_collect(ws, url)
7533
+ visited += 1
7534
+ sys.stderr.write(f"🔥 Warming profile: visited {visited}/{total} sites... ({url})\n")
7535
+ sys.stderr.flush()
7536
+ except Exception as e:
7537
+ sys.stderr.write(f"🔥 Warm: skip {url} ({str(e)[:60]})\n")
7538
+ continue
7539
+
7540
+ # Light scroll to look engaged (reuses humanized scroll).
7541
+ try:
7542
+ await _humanize_scroll(ws, rnd.randint(400, 1200))
7543
+ except Exception:
7544
+ pass
7545
+
7546
+ # Random aging delay 5-15s, but never overrun the budget.
7547
+ remaining = deadline - time.time()
7548
+ if remaining <= 0:
7549
+ break
7550
+ await asyncio.sleep(min(rnd.uniform(5, 15), max(0.1, remaining)))
7551
+
7552
+ print(json.dumps({
7553
+ 'warmed': True, 'visited': visited, 'sites': total,
7554
+ 'budget_minutes': budget_min,
7555
+ }, ensure_ascii=False))
7556
+
7557
+
7558
+ async def cmd_profile_dispatch(args: list) -> None:
7559
+ """Top-level dispatcher for 'cdpilot profile <subcommand> [args...]'.
7560
+
7561
+ Subcommands:
7562
+ warm [--minutes N] [--sites url1,url2] Warm up profile (reCAPTCHA v3 aging).
7563
+ """
7564
+ if not args:
7565
+ print(
7566
+ "Usage: cdpilot profile <subcommand>\n"
7567
+ " warm [--minutes N] [--sites url1,url2,...] Age cookies/history to boost reCAPTCHA v3 score",
7568
+ file=sys.stderr,
7569
+ )
7570
+ sys.exit(1)
7571
+
7572
+ sub = args[0].lower()
7573
+ rest = args[1:]
7574
+
7575
+ def _get_flag(flag):
7576
+ for i, a in enumerate(rest):
7577
+ if a == flag and i + 1 < len(rest):
7578
+ return rest[i + 1]
7579
+ if a.startswith(flag + '='):
7580
+ return a.split('=', 1)[1]
7581
+ return None
7582
+
7583
+ if sub == 'warm':
7584
+ await cmd_profile_warm(
7585
+ minutes=_get_flag('--minutes') or _get_flag('-m'),
7586
+ sites=_get_flag('--sites'),
7587
+ )
7588
+ else:
7589
+ print(f"Unknown profile subcommand: {sub}", file=sys.stderr)
7590
+ sys.exit(1)
7591
+
7592
+
7593
+ # ─── End Captcha Solver Plugin ───────────────────────────────────────────
7594
+
7595
+
7596
+ def _stop_browser_on_port(port):
7597
+ """Stop the browser process listening on the given port."""
7598
+ import signal
7599
+ try:
7600
+ # Bounded: on some hosts/sandboxes `lsof`/`pkill` can hang instead of
7601
+ # erroring (observed while testing the port-registry fix — a stuck
7602
+ # `lsof -ti :<port>` blocked the whole /v1/sessions DELETE request).
7603
+ # A timeout keeps this best-effort call from ever wedging the caller.
7604
+ result = subprocess.run(
7605
+ ["lsof", "-ti", f":{port}"],
7606
+ capture_output=True, text=True, timeout=5
7607
+ )
7608
+ pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
7609
+ if pids:
7610
+ for pid in pids:
7611
+ os.kill(int(pid), signal.SIGTERM)
7612
+ return True
7613
+ else:
7614
+ subprocess.run(
7615
+ ["pkill", "-f", f"remote-debugging-port={port}"],
7616
+ capture_output=True, text=True, timeout=5
7617
+ )
7618
+ return True
7619
+ except Exception:
7620
+ return False
7621
+
7622
+
7623
+ # ─── Browserbase-Compatible Local API ───
7624
+
7625
+ DEFAULT_MAX_SESSIONS = int(os.environ.get('CDPILOT_MAX_SESSIONS', '10'))
7626
+ _api_session_store: dict = {} # session_id -> session_dict
7627
+
7628
+
7629
+ def _api_make_session_id() -> str:
7630
+ """Generate sess_<8 hex chars> unique ID."""
7631
+ return 'sess_' + secrets.token_hex(4)
7632
+
7633
+
7634
+ def _api_create_session(opts: dict) -> dict:
7635
+ """Launch browser for a new API session. Returns session dict."""
7636
+ if len(_api_session_store) >= DEFAULT_MAX_SESSIONS:
7637
+ raise ValueError("Maximum session limit reached")
7638
+
7639
+ session_id = _api_make_session_id()
7640
+ proj_id = f'api-{session_id}'
7641
+ port = _allocate_port(proj_id)
7642
+ created_at = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
7643
+
7644
+ sess = {
7645
+ 'id': session_id,
7646
+ 'createdAt': created_at,
7647
+ 'projectId': proj_id,
7648
+ 'status': 'RUNNING',
7649
+ 'connectUrl': '',
6145
7650
  'seleniumRemoteUrl': None,
6146
7651
  'signingKey': None,
6147
7652
  'port': port,
@@ -6189,7 +7694,11 @@ def _api_release_session(session_id: str) -> bool:
6189
7694
  if not sess:
6190
7695
  return False
6191
7696
  port = sess.get('port')
6192
- if port:
7697
+ # In TEST_MODE, _api_create_session never actually launched a browser on
7698
+ # `port` (see the sibling check there) — nothing is listening, so skip
7699
+ # the lsof/pkill round trip entirely instead of paying its cost for a
7700
+ # no-op every time.
7701
+ if port and os.environ.get('CDPILOT_API_TEST_MODE') != '1':
6193
7702
  _stop_browser_on_port(port)
6194
7703
  sess['status'] = 'STOPPED'
6195
7704
  del _api_session_store[session_id]
@@ -6989,16 +8498,48 @@ async def cmd_smart_click(text):
6989
8498
  cdpilot smart-click "Learn more"
6990
8499
  """
6991
8500
  ws_url, _ = get_page_ws()
8501
+ # Snapshot page targets before the click so a new tab opened by the click
8502
+ # (target=_blank, window.open) can be marked cdpilot-owned afterwards.
8503
+ _pre_click_targets = {t.get("id") for t in (cdp_get("/json") or [])
8504
+ if t.get("type") == "page"}
8505
+ # Locale-aware lowercase so Turkish/German chars round-trip safely.
8506
+ # `toLocaleLowerCase()` (no arg) honors the browser's BCP-47 locale, which
8507
+ # matches what the user sees in the DOM. Python's str.lower() handles
8508
+ # ASCII fine for the *search* term that we marshal across; the in-page
8509
+ # comparison uses the same locale-aware function on both sides.
6992
8510
  safe_text = json.dumps(text.lower())
6993
8511
  js = f"""
6994
8512
  (function() {{
6995
- var search = {safe_text};
8513
+ // Walk into shadow roots so Salesforce Lightning / Polymer / any custom
8514
+ // element with attachShadow({{mode:'open'}}) becomes searchable.
8515
+ // Closed shadow roots are inaccessible by design — nothing we can do
8516
+ // about those without a DevTools-only API.
8517
+ function deepQuerySelectorAll(root, selector) {{
8518
+ var found = Array.from(root.querySelectorAll(selector));
8519
+ Array.from(root.querySelectorAll('*')).forEach(function(el) {{
8520
+ if (el.shadowRoot) {{
8521
+ found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
8522
+ }}
8523
+ }});
8524
+ return found;
8525
+ }}
8526
+
8527
+ function lc(s) {{
8528
+ // Locale-aware lowercase — `toLowerCase()` mishandles Turkish (İ → i̇
8529
+ // combining sequence in some engines) and stays case-sensitive on
8530
+ // ß in older runtimes. `toLocaleLowerCase()` matches the user's
8531
+ // expected mental model: "İletişim" → "iletişim".
8532
+ return (s == null ? '' : (s + '')).toLocaleLowerCase();
8533
+ }}
8534
+
8535
+ var search = lc({safe_text}).trim();
6996
8536
  var candidates = [];
8537
+ var disabledCount = 0;
6997
8538
 
6998
8539
  // Score: exact > startsWith > includes > partial
6999
8540
  function score(str) {{
7000
8541
  if (!str) return 0;
7001
- var s = str.toLowerCase().trim();
8542
+ var s = lc(str).trim();
7002
8543
  if (s === search) return 100;
7003
8544
  if (s.startsWith(search)) return 80;
7004
8545
  if (s.includes(search)) return 60;
@@ -7009,13 +8550,13 @@ async def cmd_smart_click(text):
7009
8550
  return 0;
7010
8551
  }}
7011
8552
 
7012
- var els = document.querySelectorAll(
8553
+ var els = deepQuerySelectorAll(document,
7013
8554
  'a, button, input[type=submit], input[type=button], ' +
7014
8555
  '[role=button], [role=link], [role=tab], [role=menuitem], ' +
7015
8556
  'summary, label, [onclick], [tabindex]'
7016
8557
  );
7017
8558
 
7018
- Array.from(els).forEach(function(el) {{
8559
+ els.forEach(function(el) {{
7019
8560
  var rect = el.getBoundingClientRect();
7020
8561
  if (rect.width === 0 && rect.height === 0) return;
7021
8562
  var style = window.getComputedStyle(el);
@@ -7034,10 +8575,23 @@ async def cmd_smart_click(text):
7034
8575
  var bestMatch = '';
7035
8576
  texts.forEach(function(t) {{
7036
8577
  var s = score(t);
7037
- if (s > bestScore) {{ bestScore = s; bestMatch = t.trim().substring(0, 60); }}
8578
+ if (s > bestScore) {{ bestScore = s; bestMatch = (t + '').trim().substring(0, 60); }}
7038
8579
  }});
7039
8580
 
7040
8581
  if (bestScore > 0) {{
8582
+ // Reject elements that look clickable but won't fire — `<button
8583
+ // disabled>`, `[aria-disabled=true]`, or a disabled ancestor
8584
+ // (fieldset[disabled] propagates to children). Without this the
8585
+ // command reports "Clicked" but nothing happens — silent fail.
8586
+ var isDisabled = (
8587
+ el.disabled === true ||
8588
+ el.getAttribute('aria-disabled') === 'true' ||
8589
+ (typeof el.closest === 'function' && el.closest('fieldset[disabled], [aria-disabled="true"]') !== null)
8590
+ );
8591
+ if (isDisabled) {{
8592
+ disabledCount++;
8593
+ return;
8594
+ }}
7041
8595
  candidates.push({{
7042
8596
  el: el,
7043
8597
  score: bestScore,
@@ -7049,7 +8603,13 @@ async def cmd_smart_click(text):
7049
8603
  }}
7050
8604
  }});
7051
8605
 
7052
- if (candidates.length === 0) return JSON.stringify({{found: false}});
8606
+ if (candidates.length === 0) {{
8607
+ return JSON.stringify({{
8608
+ found: false,
8609
+ allDisabled: disabledCount > 0,
8610
+ disabledCount: disabledCount
8611
+ }});
8612
+ }}
7053
8613
 
7054
8614
  candidates.sort(function(a, b) {{ return b.score - a.score; }});
7055
8615
  var best = candidates[0];
@@ -7078,10 +8638,26 @@ async def cmd_smart_click(text):
7078
8638
  return
7079
8639
 
7080
8640
  if not data.get("found"):
7081
- print(f'No element found matching: "{text}"', file=sys.stderr)
8641
+ if data.get("allDisabled"):
8642
+ # Distinguish "no match" from "match exists but disabled" — the
8643
+ # latter is almost always a timing bug in the caller (form
8644
+ # validation hasn't unlocked the submit button yet).
8645
+ print(f'Error: no enabled element matches "{text}" '
8646
+ f'({data.get("disabledCount", 0)} disabled match(es) skipped)', file=sys.stderr)
8647
+ else:
8648
+ print(f'No element found matching: "{text}"', file=sys.stderr)
7082
8649
  sys.exit(1)
7083
8650
 
7084
8651
  await _vfx_ripple(ws_url, data["x"], data["y"])
8652
+ # A click can spawn a new tab (target=_blank). Diff the target list and mark
8653
+ # any newcomer as cdpilot-owned so `close` will clean it up later.
8654
+ try:
8655
+ cdp_cache_invalidate()
8656
+ for t in (cdp_get("/json", no_cache=True) or []):
8657
+ if t.get("type") == "page" and t.get("id") not in _pre_click_targets:
8658
+ _mark_owned_tab(t.get("id"))
8659
+ except Exception:
8660
+ pass
7085
8661
  print(f'Clicked: {data["tag"].upper()} "{data["text"]}" (score:{data["score"]})')
7086
8662
  if data.get("alternatives"):
7087
8663
  print(f' Also found: {", ".join(data["alternatives"])}')
@@ -7287,7 +8863,9 @@ async def cmd_dismiss(repeat=None):
7287
8863
  async def cmd_smart_fill(text, value):
7288
8864
  """Fill input by label/placeholder text — no CSS selector needed.
7289
8865
 
7290
- Finds input by: associated label, placeholder, aria-label, name, id match.
8866
+ Finds input by: associated label, placeholder, aria-label, name, id match,
8867
+ aria-labelledby, closest [aria-label], and nearby (4-prev) labels for
8868
+ floating-label designs (Material UI, Ant Design, Chakra).
7291
8869
 
7292
8870
  Usage:
7293
8871
  cdpilot smart-fill "Email" "test@example.com"
@@ -7299,44 +8877,97 @@ async def cmd_smart_fill(text, value):
7299
8877
  safe_value = json.dumps(value)
7300
8878
  js = f"""
7301
8879
  (function() {{
7302
- var search = {safe_text};
8880
+ // Shadow DOM traversal — same helper as smart-click. Custom-element
8881
+ // form controls (e.g. <sf-input> in Salesforce Lightning) only
8882
+ // expose their <input> via shadow root.
8883
+ function deepQuerySelectorAll(root, selector) {{
8884
+ var found = Array.from(root.querySelectorAll(selector));
8885
+ Array.from(root.querySelectorAll('*')).forEach(function(el) {{
8886
+ if (el.shadowRoot) {{
8887
+ found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
8888
+ }}
8889
+ }});
8890
+ return found;
8891
+ }}
8892
+
8893
+ function lc(s) {{
8894
+ return (s == null ? '' : (s + '')).toLocaleLowerCase();
8895
+ }}
8896
+
8897
+ var search = lc({safe_text}).trim();
7303
8898
  var value = {safe_value};
7304
8899
  var candidates = [];
7305
8900
 
7306
8901
  function score(str) {{
7307
8902
  if (!str) return 0;
7308
- var s = str.toLowerCase().trim();
8903
+ var s = lc(str).trim();
7309
8904
  if (s === search) return 100;
7310
8905
  if (s.startsWith(search)) return 80;
7311
8906
  if (s.includes(search)) return 60;
7312
8907
  return 0;
7313
8908
  }}
7314
8909
 
7315
- var inputs = document.querySelectorAll('input, textarea, select, [contenteditable=true]');
7316
- Array.from(inputs).forEach(function(el) {{
8910
+ var inputs = deepQuerySelectorAll(document,
8911
+ 'input, textarea, select, [contenteditable=true]');
8912
+ inputs.forEach(function(el) {{
7317
8913
  var rect = el.getBoundingClientRect();
7318
8914
  if (rect.width === 0 && rect.height === 0) return;
7319
8915
 
7320
8916
  var scores = [];
7321
- // Check placeholder
8917
+ // 1. placeholder
7322
8918
  scores.push(score(el.getAttribute('placeholder') || ''));
7323
- // Check aria-label
8919
+ // 2. aria-label
7324
8920
  scores.push(score(el.getAttribute('aria-label') || ''));
7325
- // Check name/id
8921
+ // 3. name / id
7326
8922
  scores.push(score(el.name || ''));
7327
8923
  scores.push(score(el.id || ''));
7328
- // Check associated label
8924
+ // 4. label[for=id] (classic)
7329
8925
  if (el.id) {{
7330
8926
  var label = document.querySelector('label[for="' + el.id + '"]');
7331
8927
  if (label) scores.push(score(label.textContent || ''));
7332
8928
  }}
7333
- // Check parent label
8929
+ // 5. parent <label>
7334
8930
  var parentLabel = el.closest('label');
7335
8931
  if (parentLabel) scores.push(score(parentLabel.textContent || ''));
7336
- // Check preceding text node/label
8932
+ // 6. immediately-preceding sibling (legacy fallback)
7337
8933
  var prev = el.previousElementSibling;
7338
8934
  if (prev) scores.push(score(prev.textContent || ''));
7339
8935
 
8936
+ // 7. aria-labelledby — the input points at one or more IDs whose
8937
+ // textContent forms the accessible name. ARIA spec says space-
8938
+ // separated tokens, in order.
8939
+ var labelledby = el.getAttribute('aria-labelledby');
8940
+ if (labelledby) {{
8941
+ labelledby.split(/\\s+/).forEach(function(id) {{
8942
+ if (!id) return;
8943
+ var ref = document.getElementById(id);
8944
+ if (ref) scores.push(score(ref.textContent || ''));
8945
+ }});
8946
+ }}
8947
+ // 8. closest ancestor with [aria-label] — Material UI / Chakra wrap
8948
+ // the input in a labelled container instead of using <label>.
8949
+ if (typeof el.closest === 'function') {{
8950
+ var aria = el.closest('[aria-label]');
8951
+ if (aria && aria !== el) scores.push(score(aria.getAttribute('aria-label') || ''));
8952
+ }}
8953
+ // 9. nearby label — walk up to 4 previous siblings looking for a
8954
+ // <label> or label-like element. Floating-label designs render
8955
+ // the label as a separate sibling at the parent level.
8956
+ var node = el;
8957
+ for (var depth = 0; depth < 2 && node && node.parentElement; depth++) {{
8958
+ var sib = node.previousElementSibling;
8959
+ for (var i = 0; i < 4 && sib; i++) {{
8960
+ if (sib.tagName === 'LABEL' ||
8961
+ sib.matches && (sib.matches('label, [class*="label" i], [class*="Label"]'))) {{
8962
+ var t = (sib.textContent || '').trim();
8963
+ if (t && t.length < 80) scores.push(score(t));
8964
+ break;
8965
+ }}
8966
+ sib = sib.previousElementSibling;
8967
+ }}
8968
+ node = node.parentElement;
8969
+ }}
8970
+
7340
8971
  var bestScore = Math.max.apply(null, scores);
7341
8972
  if (bestScore > 0) {{
7342
8973
  candidates.push({{el: el, score: bestScore, tag: el.tagName.toLowerCase(), type: el.type || ''}});
@@ -7400,13 +9031,38 @@ async def cmd_smart_select(text, option_text):
7400
9031
  safe_option = json.dumps(option_text.lower())
7401
9032
  js = f"""
7402
9033
  (function() {{
7403
- var search = {safe_text};
7404
- var optSearch = {safe_option};
7405
- var selects = document.querySelectorAll('select');
9034
+ // Shadow DOM + locale-aware comparison — same conventions as smart-click
9035
+ // / smart-fill so all three commands behave consistently on Lightning,
9036
+ // Polymer, and Turkish/German content.
9037
+ function deepQuerySelectorAll(root, selector) {{
9038
+ var found = Array.from(root.querySelectorAll(selector));
9039
+ Array.from(root.querySelectorAll('*')).forEach(function(el) {{
9040
+ if (el.shadowRoot) {{
9041
+ found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
9042
+ }}
9043
+ }});
9044
+ return found;
9045
+ }}
9046
+
9047
+ function lc(s) {{
9048
+ return (s == null ? '' : (s + '')).toLocaleLowerCase();
9049
+ }}
9050
+
9051
+ var search = lc({safe_text}).trim();
9052
+ var optSearch = lc({safe_option}).trim();
9053
+ var selects = deepQuerySelectorAll(document, 'select');
7406
9054
  var best = null;
7407
9055
  var bestScore = 0;
7408
-
7409
- Array.from(selects).forEach(function(sel) {{
9056
+ var disabledCount = 0;
9057
+
9058
+ selects.forEach(function(sel) {{
9059
+ // Skip disabled selects — same rationale as smart-click: silently
9060
+ // setting .value on a disabled <select> is a no-op and confusing.
9061
+ var isDisabled = (
9062
+ sel.disabled === true ||
9063
+ sel.getAttribute('aria-disabled') === 'true' ||
9064
+ (typeof sel.closest === 'function' && sel.closest('fieldset[disabled], [aria-disabled="true"]') !== null)
9065
+ );
7410
9066
  var texts = [
7411
9067
  sel.getAttribute('aria-label') || '',
7412
9068
  sel.name || '', sel.id || ''
@@ -7418,21 +9074,30 @@ async def cmd_smart_select(text, option_text):
7418
9074
  var parent = sel.closest('label');
7419
9075
  if (parent) texts.push(parent.textContent || '');
7420
9076
 
9077
+ var matched = false;
7421
9078
  texts.forEach(function(t) {{
7422
- var s = t.toLowerCase().trim();
9079
+ var s = lc(t).trim();
7423
9080
  var sc = s === search ? 100 : s.includes(search) ? 60 : 0;
7424
- if (sc > bestScore) {{ bestScore = sc; best = sel; }}
9081
+ if (sc > 0) matched = true;
9082
+ if (sc > bestScore && !isDisabled) {{ bestScore = sc; best = sel; }}
7425
9083
  }});
9084
+ if (matched && isDisabled) disabledCount++;
7426
9085
  }});
7427
9086
 
7428
- if (!best) return JSON.stringify({{found: false}});
9087
+ if (!best) {{
9088
+ return JSON.stringify({{
9089
+ found: false,
9090
+ allDisabled: disabledCount > 0,
9091
+ disabledCount: disabledCount
9092
+ }});
9093
+ }}
7429
9094
 
7430
9095
  // Find matching option
7431
9096
  var options = Array.from(best.options);
7432
9097
  var match = options.find(function(o) {{
7433
- return o.text.toLowerCase().trim() === optSearch;
9098
+ return lc(o.text).trim() === optSearch;
7434
9099
  }}) || options.find(function(o) {{
7435
- return o.text.toLowerCase().includes(optSearch);
9100
+ return lc(o.text).includes(optSearch);
7436
9101
  }});
7437
9102
 
7438
9103
  if (!match) return JSON.stringify({{found: true, optionFound: false, available: options.map(function(o) {{ return o.text; }}).slice(0, 10)}});
@@ -7451,7 +9116,11 @@ async def cmd_smart_select(text, option_text):
7451
9116
  return
7452
9117
 
7453
9118
  if not data.get("found"):
7454
- print(f'No select found matching: "{text}"', file=sys.stderr)
9119
+ if data.get("allDisabled"):
9120
+ print(f'Error: no enabled select matches "{text}" '
9121
+ f'({data.get("disabledCount", 0)} disabled match(es) skipped)', file=sys.stderr)
9122
+ else:
9123
+ print(f'No select found matching: "{text}"', file=sys.stderr)
7455
9124
  sys.exit(1)
7456
9125
  if not data.get("optionFound"):
7457
9126
  print(f'Option "{option_text}" not found. Available: {", ".join(data.get("available", []))}', file=sys.stderr)
@@ -9567,8 +11236,8 @@ class MCPServer:
9567
11236
  "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
11237
  {"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
11238
  "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": {}}},
11239
+ {"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.",
11240
+ "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
11241
  {"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
11242
  "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
11243
  {"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 +11246,11 @@ class MCPServer:
9577
11246
  "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
11247
  {"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
11248
  "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"}}}},
11249
+ {"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.",
11250
+ "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"}}}},
11251
+ {"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."}}}},
11252
+ {"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.",
11253
+ "inputSchema": {"type": "object", "properties": {}}},
9580
11254
  {"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
11255
  "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
11256
  {"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 +11281,32 @@ class MCPServer:
9607
11281
  "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element expected to be hidden"}}, "required": ["selector"]}},
9608
11282
  {"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
11283
  "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"]}},
11284
+ {"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.",
11285
+ "inputSchema": {"type": "object", "properties": {
11286
+ "url": {"type": "string", "description": "URL or file:// path to load before recording. Pass '-' or omit to attach to the currently-loaded page."},
11287
+ "fps": {"type": "integer", "description": "Frames per second (1-30). Higher = more detail but bigger ring buffer.", "default": 10},
11288
+ "quality": {"type": "integer", "description": "JPEG quality 1-100.", "default": 70},
11289
+ "max_width": {"type": "integer", "description": "Maximum frame width in pixels (shrinks proportionally).", "default": 1280},
11290
+ "retention_s": {"type": "integer", "description": "Seconds to keep frames before eviction.", "default": 300},
11291
+ "disk_cap_mb": {"type": "integer", "description": "Maximum total frame size in MB before oldest-first eviction.", "default": 100},
11292
+ "seek": {"type": "string", "description": "Optional MM:SS or seconds to seek the page's first <video> element to."}
11293
+ }}},
11294
+ {"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.",
11295
+ "inputSchema": {"type": "object", "properties": {
11296
+ "keep_frames": {"type": "boolean", "description": "Keep the captured frames on disk instead of deleting them.", "default": False}
11297
+ }}},
11298
+ {"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.",
11299
+ "inputSchema": {"type": "object", "properties": {
11300
+ "at": {"type": "string", "description": "Video time to center on, MM:SS or seconds (e.g. '1:23'). Measured from screencast start."},
11301
+ "window": {"type": "string", "description": "Width of the window around `at`, e.g. '5s', '500ms'.", "default": "5s"},
11302
+ "last": {"type": "string", "description": "Return frames from the last N seconds (e.g. '5s'). Relative to the newest frame on disk."},
11303
+ "since_last": {"type": "boolean", "description": "Return only frames newer than the previous watch_query call."},
11304
+ "max": {"type": "integer", "description": "Maximum number of frame paths to return (downsampled if window has more).", "default": 16}
11305
+ }}},
11306
+ {"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.",
11307
+ "inputSchema": {"type": "object", "properties": {}}},
11308
+ {"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.",
11309
+ "inputSchema": {"type": "object", "properties": {"tier": {"type": "string", "enum": ["regular", "stealth", "undetected"], "description": "Tier to set. Omit to get the current tier."}}}},
9610
11310
  ]
9611
11311
 
9612
11312
  def _handle_request(self, request):
@@ -9657,12 +11357,15 @@ class MCPServer:
9657
11357
  "browser_a11y": lambda a: ["a11y"] + ([a["mode"]] if a.get("mode") and a["mode"] != "full" else []),
9658
11358
  "browser_fill": lambda a: ["fill", a.get("selector", ""), a.get("value", "")],
9659
11359
  "browser_launch": lambda a: ["launch"],
9660
- "browser_close": lambda a: ["close"],
11360
+ "browser_close": lambda a: ["close"] + (["--force"] if a.get("force") else []) + (["--keep"] if a.get("keep_browser") else []),
9661
11361
  "browser_extract": lambda a: ["extract", a.get("selector", "")] + ([f"--{a['format']}"] if a.get("format") and a["format"] != "text" else []),
9662
11362
  "browser_observe": lambda a: ["observe"],
9663
11363
  "browser_smart_click": lambda a: ["smart-click", a.get("text", "")],
9664
11364
  "browser_dismiss": lambda a: ["dismiss"] + ([str(a["repeat"])] if a.get("repeat") else []),
9665
11365
  "browser_smart_fill": lambda a: ["smart-fill", a.get("label", ""), a.get("value", "")],
11366
+ "browser_captcha_solve": lambda a: ["captcha-solve"] + ([f"--provider={a['provider']}"] if a.get("provider") else []),
11367
+ "browser_press_hold": lambda a: ["press-hold"] + ([a["selector"]] if a.get("selector") else []),
11368
+ "browser_friction": lambda a: ["friction"],
9666
11369
  "browser_smart_select": lambda a: ["smart-select", a.get("label", ""), a.get("option", "")],
9667
11370
  "browser_describe": lambda a: ["describe"],
9668
11371
  "browser_assert": lambda a: ["assert", a.get("selector", "")] + ([a["text"]] if a.get("text") else []),
@@ -9677,6 +11380,11 @@ class MCPServer:
9677
11380
  "browser_assert_visible": lambda a: ["assert-visible", a.get("selector", "")],
9678
11381
  "browser_assert_hidden": lambda a: ["assert-hidden", a.get("selector", "")],
9679
11382
  "browser_screenshot_diff": lambda a: ["screenshot-diff", a.get("path1", ""), a.get("path2", "")],
11383
+ "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 []),
11384
+ "browser_watch_stop": lambda a: ["watch", "stop"] + (["--keep-frames"] if a.get("keep_frames") else []),
11385
+ "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 []),
11386
+ "browser_watch_status": lambda a: ["watch", "status"],
11387
+ "browser_mode": lambda a: ["mode"] + ([a["tier"]] if a.get("tier") else []),
9680
11388
  }
9681
11389
  if tool_name not in tool_map:
9682
11390
  return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32602, "message": f"Unknown tool: {tool_name}"}}
@@ -10409,6 +12117,817 @@ def _dispatch_blog_cmd(args):
10409
12117
  # ─── End Blog Publish Namespace ───────────────────────────────────────────────
10410
12118
 
10411
12119
 
12120
+ # ─── cdpilot watch — continuous screencast for AI video understanding ─────────
12121
+ #
12122
+ # Why this exists: still screenshots taken at command-rate intervals (~1s)
12123
+ # cannot capture animation, mouse cursors, scroll dynamics, or short visual
12124
+ # events ("kedi sola mı sağa mı koştu?"). CDP's built-in Page.startScreencast
12125
+ # streams JPEG frames at 10-30fps directly from the renderer. We tee the
12126
+ # stream onto a disk ring buffer so AI orchestrators (Claude, etc.) can query
12127
+ # any time window after the fact and get real frames to feed into a
12128
+ # multimodal API. Two-process design: a long-lived daemon owns the WS and
12129
+ # writes frames; foreground `query/status/stop` commands only read disk.
12130
+
12131
+ WATCH_DEFAULT_FPS = 10
12132
+ WATCH_DEFAULT_QUALITY = 70
12133
+ WATCH_DEFAULT_MAX_WIDTH = 1280
12134
+ WATCH_DEFAULT_RETENTION_S = 300 # 5 minutes
12135
+ WATCH_DEFAULT_DISK_CAP_MB = 100
12136
+ WATCH_DAEMON_FLAG = '--_watch-daemon' # hidden — used only for re-entrant fork
12137
+
12138
+
12139
+ def _watch_dir():
12140
+ """Return per-project watch directory (state + frames + index)."""
12141
+ pid = PROJECT_ID or _get_project_id()
12142
+ return os.path.join(CDPILOT_HOME, 'projects', pid, 'watch')
12143
+
12144
+
12145
+ def _watch_frames_dir():
12146
+ return os.path.join(_watch_dir(), 'frames')
12147
+
12148
+
12149
+ def _watch_state_path():
12150
+ return os.path.join(_watch_dir(), 'state.json')
12151
+
12152
+
12153
+ def _watch_index_path():
12154
+ return os.path.join(_watch_dir(), 'index.jsonl')
12155
+
12156
+
12157
+ def _watch_log_path():
12158
+ return os.path.join(_watch_dir(), 'daemon.log')
12159
+
12160
+
12161
+ def _watch_load_state():
12162
+ try:
12163
+ with open(_watch_state_path()) as f:
12164
+ return json.load(f)
12165
+ except (OSError, ValueError):
12166
+ return None
12167
+
12168
+
12169
+ def _watch_save_state(state):
12170
+ os.makedirs(_watch_dir(), exist_ok=True)
12171
+ tmp = _watch_state_path() + '.tmp'
12172
+ with open(tmp, 'w') as f:
12173
+ json.dump(state, f, indent=2)
12174
+ os.replace(tmp, _watch_state_path())
12175
+
12176
+
12177
+ def _watch_clear_state():
12178
+ for p in (_watch_state_path(), _watch_index_path()):
12179
+ try:
12180
+ os.remove(p)
12181
+ except OSError:
12182
+ pass
12183
+
12184
+
12185
+ def _watch_pid_alive(pid):
12186
+ # os.kill(pid, 0) terminates the process on Windows — see _pid_alive.
12187
+ return _pid_alive(pid)
12188
+
12189
+
12190
+ def _watch_parse_timecode(spec):
12191
+ """Parse '1:23', '83', '1:23.5' into seconds (float). Returns None on failure."""
12192
+ if spec is None:
12193
+ return None
12194
+ s = str(spec).strip()
12195
+ if not s:
12196
+ return None
12197
+ try:
12198
+ if ':' in s:
12199
+ mm, ss = s.split(':', 1)
12200
+ return float(mm) * 60 + float(ss)
12201
+ return float(s)
12202
+ except ValueError:
12203
+ return None
12204
+
12205
+
12206
+ def _watch_parse_window(spec):
12207
+ """Parse '5s', '2.5', '500ms' into seconds (float). Returns 5.0 on failure."""
12208
+ if spec is None:
12209
+ return 5.0
12210
+ s = str(spec).strip().lower()
12211
+ if not s:
12212
+ return 5.0
12213
+ try:
12214
+ if s.endswith('ms'):
12215
+ return float(s[:-2]) / 1000.0
12216
+ if s.endswith('s'):
12217
+ return float(s[:-1])
12218
+ return float(s)
12219
+ except ValueError:
12220
+ return 5.0
12221
+
12222
+
12223
+ def _watch_evict(retention_s, disk_cap_bytes):
12224
+ """Drop frames older than retention OR over disk cap (oldest-first).
12225
+
12226
+ Returns (removed_count, bytes_freed).
12227
+ """
12228
+ fdir = _watch_frames_dir()
12229
+ if not os.path.isdir(fdir):
12230
+ return 0, 0
12231
+ now_ms = int(time.time() * 1000)
12232
+ cutoff_ms = now_ms - int(retention_s * 1000)
12233
+
12234
+ files = []
12235
+ for name in os.listdir(fdir):
12236
+ if not name.endswith('.jpg'):
12237
+ continue
12238
+ try:
12239
+ ts_ms = int(name[:-4])
12240
+ except ValueError:
12241
+ continue
12242
+ path = os.path.join(fdir, name)
12243
+ try:
12244
+ size = os.path.getsize(path)
12245
+ except OSError:
12246
+ continue
12247
+ files.append((ts_ms, path, size))
12248
+ files.sort() # oldest first
12249
+
12250
+ removed = 0
12251
+ freed = 0
12252
+ # Age-based eviction
12253
+ keep = []
12254
+ for ts_ms, path, size in files:
12255
+ if ts_ms < cutoff_ms:
12256
+ try:
12257
+ os.remove(path)
12258
+ removed += 1
12259
+ freed += size
12260
+ except OSError:
12261
+ pass
12262
+ else:
12263
+ keep.append((ts_ms, path, size))
12264
+
12265
+ # Disk-cap eviction (drop oldest until under cap)
12266
+ total = sum(s for _, _, s in keep)
12267
+ while keep and total > disk_cap_bytes:
12268
+ ts_ms, path, size = keep.pop(0)
12269
+ try:
12270
+ os.remove(path)
12271
+ removed += 1
12272
+ freed += size
12273
+ total -= size
12274
+ except OSError:
12275
+ pass
12276
+
12277
+ return removed, freed
12278
+
12279
+
12280
+ def _watch_list_frames():
12281
+ """Scan the ring buffer dir, return sorted list of (ts_ms, path)."""
12282
+ fdir = _watch_frames_dir()
12283
+ out = []
12284
+ if not os.path.isdir(fdir):
12285
+ return out
12286
+ for name in os.listdir(fdir):
12287
+ if not name.endswith('.jpg'):
12288
+ continue
12289
+ try:
12290
+ ts_ms = int(name[:-4])
12291
+ except ValueError:
12292
+ continue
12293
+ out.append((ts_ms, os.path.join(fdir, name)))
12294
+ out.sort()
12295
+ return out
12296
+
12297
+
12298
+ def _watch_frame_diff(a_path, b_path):
12299
+ """Mean absolute pixel difference (0.0..1.0). Returns None if PIL missing.
12300
+
12301
+ Graceful degradation: if PIL/Pillow is not installed we return None and
12302
+ callers fall back to uniform frame picking.
12303
+ """
12304
+ try:
12305
+ from PIL import Image, ImageChops, ImageStat # type: ignore
12306
+ except ImportError:
12307
+ return None
12308
+ try:
12309
+ with Image.open(a_path) as a_img, Image.open(b_path) as b_img:
12310
+ a_g = a_img.convert('L').resize((160, 90))
12311
+ b_g = b_img.convert('L').resize((160, 90))
12312
+ diff = ImageChops.difference(a_g, b_g)
12313
+ stat = ImageStat.Stat(diff)
12314
+ return float(stat.mean[0]) / 255.0
12315
+ except Exception:
12316
+ return None
12317
+
12318
+
12319
+ # ─── watch daemon (the actual screencast consumer) ───
12320
+
12321
+ async def _watch_daemon_run(url, fps, quality, max_width, retention_s,
12322
+ disk_cap_bytes, seek_s):
12323
+ """Long-lived process: open WS, subscribe to screencastFrame, write JPEGs.
12324
+
12325
+ Runs until SIGTERM / parent kill. Each frame:
12326
+ 1. base64-decode payload
12327
+ 2. write to frames/<unix_ms>.jpg
12328
+ 3. ACK with Page.screencastFrameAck (required, else stream stalls)
12329
+ 4. periodically evict (every ~2s)
12330
+ """
12331
+ import websockets
12332
+
12333
+ fdir = _watch_frames_dir()
12334
+ os.makedirs(fdir, exist_ok=True)
12335
+
12336
+ # Launch browser if needed
12337
+ if not cdp_get("/json/version"):
12338
+ cmd_launch()
12339
+
12340
+ ws_url, _ = get_page_ws()
12341
+
12342
+ # Navigate first (so the page exists before we start screencasting).
12343
+ # If url is empty we just attach to whatever is currently loaded.
12344
+ if url:
12345
+ try:
12346
+ await navigate_collect(ws_url, url, glow=False)
12347
+ except Exception as e:
12348
+ sys.stderr.write(f"watch: navigate failed: {e}\n")
12349
+
12350
+ # Try to coax a <video> element into autoplay + (optional) seek.
12351
+ # Best-effort — silent if the page has no video.
12352
+ autoplay_js = (
12353
+ "(()=>{const v=document.querySelector('video');"
12354
+ "if(!v) return {ok:false,reason:'no-video'};"
12355
+ "try{v.muted=true;v.playsInline=true;"
12356
+ + (f"v.currentTime={float(seek_s)};" if seek_s is not None else "")
12357
+ + "const p=v.play();if(p&&p.catch)p.catch(()=>{});"
12358
+ "return {ok:true,duration:v.duration||0,currentTime:v.currentTime};}"
12359
+ "catch(e){return {ok:false,reason:String(e)};}})()"
12360
+ )
12361
+ try:
12362
+ await cdp_send(ws_url, [(81, "Runtime.evaluate", {
12363
+ "expression": autoplay_js, "returnByValue": True,
12364
+ })])
12365
+ except Exception:
12366
+ pass
12367
+
12368
+ # everyNthFrame: CDP samples at ~60fps native; nth=6 → ~10fps.
12369
+ every_nth = max(1, int(round(60 / max(1, fps))))
12370
+
12371
+ # We bypass the pooled cdp_send for the screencast loop because we need
12372
+ # to consume *unsolicited* event frames (Page.screencastFrame) for the
12373
+ # entire daemon lifetime. The pool model assumes request/response.
12374
+ async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
12375
+ await ws.send(json.dumps({"id": 1, "method": "Page.enable", "params": {}}))
12376
+ await ws.send(json.dumps({
12377
+ "id": 2, "method": "Page.startScreencast",
12378
+ "params": {
12379
+ "format": "jpeg",
12380
+ "quality": int(quality),
12381
+ "maxWidth": int(max_width),
12382
+ "everyNthFrame": every_nth,
12383
+ },
12384
+ }))
12385
+
12386
+ last_evict = time.time()
12387
+ frame_count = 0
12388
+ # Update state file with confirmed start so `status` shows "running"
12389
+ st = _watch_load_state() or {}
12390
+ st['screencast_started_at_ms'] = int(time.time() * 1000)
12391
+ _watch_save_state(st)
12392
+
12393
+ while True:
12394
+ try:
12395
+ resp = await asyncio.wait_for(ws.recv(), timeout=5)
12396
+ except asyncio.TimeoutError:
12397
+ # Periodic eviction even if no frames arrive (e.g. page paused)
12398
+ if time.time() - last_evict > 2.0:
12399
+ _watch_evict(retention_s, disk_cap_bytes)
12400
+ last_evict = time.time()
12401
+ continue
12402
+ try:
12403
+ data = json.loads(resp)
12404
+ except ValueError:
12405
+ continue
12406
+ if data.get("method") != "Page.screencastFrame":
12407
+ continue
12408
+ params = data.get("params", {})
12409
+ session_id = params.get("sessionId")
12410
+ b64 = params.get("data", "")
12411
+ ts_ms = int(time.time() * 1000)
12412
+
12413
+ try:
12414
+ raw = base64.b64decode(b64)
12415
+ except Exception:
12416
+ raw = b""
12417
+ if raw:
12418
+ fpath = os.path.join(fdir, f"{ts_ms}.jpg")
12419
+ try:
12420
+ with open(fpath, 'wb') as f:
12421
+ f.write(raw)
12422
+ # Append to index (best-effort; missing entries are fine,
12423
+ # query falls back to scanning the dir directly)
12424
+ try:
12425
+ with open(_watch_index_path(), 'a') as idx:
12426
+ idx.write(json.dumps({
12427
+ "ts_ms": ts_ms,
12428
+ "filename": f"{ts_ms}.jpg",
12429
+ "size": len(raw),
12430
+ }) + "\n")
12431
+ except OSError:
12432
+ pass
12433
+ frame_count += 1
12434
+ except OSError as e:
12435
+ sys.stderr.write(f"watch: write failed: {e}\n")
12436
+
12437
+ # ACK is REQUIRED — CDP stops sending frames until acknowledged.
12438
+ try:
12439
+ await ws.send(json.dumps({
12440
+ "id": 1000 + (frame_count % 10000),
12441
+ "method": "Page.screencastFrameAck",
12442
+ "params": {"sessionId": session_id},
12443
+ }))
12444
+ except Exception:
12445
+ break
12446
+
12447
+ # Periodic eviction (every ~2s)
12448
+ if time.time() - last_evict > 2.0:
12449
+ _watch_evict(retention_s, disk_cap_bytes)
12450
+ last_evict = time.time()
12451
+
12452
+
12453
+ def _cmd_watch_daemon_entry():
12454
+ """Internal: re-entrant entry point when called with --_watch-daemon flag.
12455
+
12456
+ Reads parameters from state file, runs the async screencast loop forever.
12457
+ Logs go to daemon.log so the foreground process can stay clean.
12458
+ """
12459
+ state = _watch_load_state() or {}
12460
+ url = state.get('url') or ''
12461
+ fps = int(state.get('fps') or WATCH_DEFAULT_FPS)
12462
+ quality = int(state.get('quality') or WATCH_DEFAULT_QUALITY)
12463
+ max_width = int(state.get('max_width') or WATCH_DEFAULT_MAX_WIDTH)
12464
+ retention_s = float(state.get('retention_s') or WATCH_DEFAULT_RETENTION_S)
12465
+ disk_cap_bytes = int(state.get('disk_cap_bytes') or WATCH_DEFAULT_DISK_CAP_MB * 1024 * 1024)
12466
+ seek_s = state.get('seek_s')
12467
+
12468
+ state['pid'] = os.getpid()
12469
+ state['daemon_started_at_ms'] = int(time.time() * 1000)
12470
+ _watch_save_state(state)
12471
+
12472
+ try:
12473
+ asyncio.run(_watch_daemon_run(
12474
+ url, fps, quality, max_width, retention_s, disk_cap_bytes, seek_s
12475
+ ))
12476
+ except KeyboardInterrupt:
12477
+ pass
12478
+ except Exception as e:
12479
+ try:
12480
+ with open(_watch_log_path(), 'a') as f:
12481
+ f.write(f"[{datetime.datetime.utcnow().isoformat()}] daemon crash: {e}\n")
12482
+ except OSError:
12483
+ pass
12484
+
12485
+
12486
+ def cmd_watch_start(*args):
12487
+ """Start screencast capture in a background daemon.
12488
+
12489
+ cdpilot watch start <url|file://path|-> [--fps=10] [--quality=70]
12490
+ [--max-width=1280]
12491
+ [--retention=300]
12492
+ [--disk-cap=100]
12493
+ [--seek=1:23]
12494
+
12495
+ - URL may be omitted to attach to the currently-loaded page.
12496
+ - `-` as URL also means "attach, don't navigate".
12497
+ - Idempotent: if a daemon for this project is already running it is
12498
+ stopped first (we never want two screencasts on the same target).
12499
+ """
12500
+ # Stop any prior daemon — two screencasts on one target would race.
12501
+ prev = _watch_load_state()
12502
+ if prev and _watch_pid_alive(prev.get('pid')):
12503
+ try:
12504
+ os.kill(int(prev['pid']), 15)
12505
+ except OSError:
12506
+ pass
12507
+ # brief wait so the WS releases
12508
+ for _ in range(20):
12509
+ if not _watch_pid_alive(prev.get('pid')):
12510
+ break
12511
+ time.sleep(0.05)
12512
+
12513
+ # Wipe ring buffer + index from any prior run (same project, different video)
12514
+ fdir = _watch_frames_dir()
12515
+ if os.path.isdir(fdir):
12516
+ for name in os.listdir(fdir):
12517
+ if name.endswith('.jpg'):
12518
+ try:
12519
+ os.remove(os.path.join(fdir, name))
12520
+ except OSError:
12521
+ pass
12522
+ try:
12523
+ os.remove(_watch_index_path())
12524
+ except OSError:
12525
+ pass
12526
+
12527
+ # Parse args
12528
+ url = ''
12529
+ fps = WATCH_DEFAULT_FPS
12530
+ quality = WATCH_DEFAULT_QUALITY
12531
+ max_width = WATCH_DEFAULT_MAX_WIDTH
12532
+ retention_s = WATCH_DEFAULT_RETENTION_S
12533
+ disk_cap_mb = WATCH_DEFAULT_DISK_CAP_MB
12534
+ seek_s = None
12535
+
12536
+ positional = []
12537
+ for a in args:
12538
+ if a.startswith('--fps='):
12539
+ try:
12540
+ fps = max(1, min(30, int(a.split('=', 1)[1])))
12541
+ except ValueError:
12542
+ pass
12543
+ elif a.startswith('--quality='):
12544
+ try:
12545
+ quality = max(1, min(100, int(a.split('=', 1)[1])))
12546
+ except ValueError:
12547
+ pass
12548
+ elif a.startswith('--max-width='):
12549
+ try:
12550
+ max_width = max(160, min(3840, int(a.split('=', 1)[1])))
12551
+ except ValueError:
12552
+ pass
12553
+ elif a.startswith('--retention='):
12554
+ try:
12555
+ retention_s = max(5, int(a.split('=', 1)[1]))
12556
+ except ValueError:
12557
+ pass
12558
+ elif a.startswith('--disk-cap='):
12559
+ try:
12560
+ disk_cap_mb = max(1, int(a.split('=', 1)[1]))
12561
+ except ValueError:
12562
+ pass
12563
+ elif a.startswith('--seek='):
12564
+ seek_s = _watch_parse_timecode(a.split('=', 1)[1])
12565
+ elif not a.startswith('--'):
12566
+ positional.append(a)
12567
+
12568
+ if positional:
12569
+ url = positional[0]
12570
+ if url == '-':
12571
+ url = ''
12572
+
12573
+ os.makedirs(_watch_frames_dir(), exist_ok=True)
12574
+ state = {
12575
+ 'url': url,
12576
+ 'fps': fps,
12577
+ 'quality': quality,
12578
+ 'max_width': max_width,
12579
+ 'retention_s': retention_s,
12580
+ 'disk_cap_bytes': disk_cap_mb * 1024 * 1024,
12581
+ 'seek_s': seek_s,
12582
+ 'started_at_ms': int(time.time() * 1000),
12583
+ 'project_id': PROJECT_ID,
12584
+ 'pid': None,
12585
+ }
12586
+ _watch_save_state(state)
12587
+
12588
+ # Fork the daemon. Use the same python + this script + the hidden flag.
12589
+ script = os.path.abspath(__file__)
12590
+ log_f = None
12591
+ try:
12592
+ log_f = open(_watch_log_path(), 'a')
12593
+ except OSError:
12594
+ log_f = subprocess.DEVNULL
12595
+
12596
+ env = os.environ.copy()
12597
+ env['CDPILOT_PROJECT_ID'] = PROJECT_ID or _get_project_id()
12598
+ # The daemon must use the same CDP port (same project's browser)
12599
+ env['CDP_PORT'] = str(CDP_PORT)
12600
+
12601
+ proc = subprocess.Popen(
12602
+ [sys.executable, script, WATCH_DAEMON_FLAG],
12603
+ stdout=log_f, stderr=log_f,
12604
+ env=env,
12605
+ start_new_session=True, # detach from this terminal's process group
12606
+ )
12607
+ state['pid'] = proc.pid
12608
+ _watch_save_state(state)
12609
+
12610
+ # Wait briefly for the daemon to confirm screencast start
12611
+ confirmed = False
12612
+ for _ in range(40): # up to ~2s
12613
+ time.sleep(0.05)
12614
+ st = _watch_load_state() or {}
12615
+ if st.get('screencast_started_at_ms'):
12616
+ confirmed = True
12617
+ break
12618
+ if not _watch_pid_alive(proc.pid):
12619
+ break
12620
+
12621
+ out = {
12622
+ 'ok': confirmed or _watch_pid_alive(proc.pid),
12623
+ 'pid': proc.pid,
12624
+ 'url': url or '(attach)',
12625
+ 'fps': fps,
12626
+ 'quality': quality,
12627
+ 'max_width': max_width,
12628
+ 'retention_s': retention_s,
12629
+ 'disk_cap_mb': disk_cap_mb,
12630
+ 'frames_dir': _watch_frames_dir(),
12631
+ 'state_path': _watch_state_path(),
12632
+ }
12633
+ print(json.dumps(out, indent=2))
12634
+ if not out['ok']:
12635
+ sys.exit(1)
12636
+
12637
+
12638
+ def cmd_watch_stop(*args):
12639
+ """Stop the watch daemon and optionally cleanup frames."""
12640
+ keep_frames = '--keep-frames' in args
12641
+ state = _watch_load_state()
12642
+ if not state:
12643
+ print(json.dumps({'ok': True, 'message': 'no active watch session'}))
12644
+ return
12645
+
12646
+ pid = state.get('pid')
12647
+ killed = False
12648
+ if pid and _watch_pid_alive(pid):
12649
+ try:
12650
+ os.kill(int(pid), 15)
12651
+ killed = True
12652
+ except OSError:
12653
+ pass
12654
+ for _ in range(40):
12655
+ if not _watch_pid_alive(pid):
12656
+ break
12657
+ time.sleep(0.05)
12658
+ if _watch_pid_alive(pid):
12659
+ try:
12660
+ os.kill(int(pid), 9)
12661
+ except OSError:
12662
+ pass
12663
+
12664
+ removed = 0
12665
+ if not keep_frames:
12666
+ fdir = _watch_frames_dir()
12667
+ if os.path.isdir(fdir):
12668
+ for name in os.listdir(fdir):
12669
+ if name.endswith('.jpg'):
12670
+ try:
12671
+ os.remove(os.path.join(fdir, name))
12672
+ removed += 1
12673
+ except OSError:
12674
+ pass
12675
+ try:
12676
+ os.remove(_watch_index_path())
12677
+ except OSError:
12678
+ pass
12679
+
12680
+ _watch_clear_state()
12681
+ print(json.dumps({
12682
+ 'ok': True,
12683
+ 'killed': killed,
12684
+ 'pid': pid,
12685
+ 'frames_removed': removed,
12686
+ 'kept_frames': keep_frames,
12687
+ }, indent=2))
12688
+
12689
+
12690
+ def cmd_watch_status(*args):
12691
+ """Report daemon state, frame count, disk usage."""
12692
+ state = _watch_load_state()
12693
+ frames = _watch_list_frames()
12694
+ total_size = sum(os.path.getsize(p) for _, p in frames if os.path.exists(p)) if frames else 0
12695
+
12696
+ out = {
12697
+ 'running': bool(state and _watch_pid_alive(state.get('pid'))),
12698
+ 'frames': len(frames),
12699
+ 'disk_bytes': total_size,
12700
+ 'disk_mb': round(total_size / (1024 * 1024), 2),
12701
+ 'oldest_ts_ms': frames[0][0] if frames else None,
12702
+ 'newest_ts_ms': frames[-1][0] if frames else None,
12703
+ }
12704
+ if state:
12705
+ out['pid'] = state.get('pid')
12706
+ out['url'] = state.get('url') or '(attach)'
12707
+ out['fps'] = state.get('fps')
12708
+ out['started_at_ms'] = state.get('started_at_ms')
12709
+ out['screencast_started_at_ms'] = state.get('screencast_started_at_ms')
12710
+ out['retention_s'] = state.get('retention_s')
12711
+ out['disk_cap_mb'] = (state.get('disk_cap_bytes') or 0) // (1024 * 1024)
12712
+ print(json.dumps(out, indent=2))
12713
+
12714
+
12715
+ def _watch_last_query_path():
12716
+ return os.path.join(_watch_dir(), 'last-query.json')
12717
+
12718
+
12719
+ def cmd_watch_query(*args):
12720
+ """Return frame paths inside a time window as JSON.
12721
+
12722
+ cdpilot watch query --at <mm:ss|sec> --window <Ns> [--max=16]
12723
+ cdpilot watch query --since-last [--max=16]
12724
+ cdpilot watch query --last <Ns> [--max=16] (relative to newest frame)
12725
+
12726
+ Time mapping: --at is interpreted as VIDEO time, measured from the moment
12727
+ the screencast started (state['screencast_started_at_ms']). This matches
12728
+ user intuition for "what happened at 0:35" because we begin capturing
12729
+ right after autoplay.
12730
+ """
12731
+ state = _watch_load_state()
12732
+ if not state:
12733
+ print(json.dumps({'frames': [], 'count': 0, 'error': 'no watch session'}))
12734
+ return
12735
+
12736
+ max_frames = 16
12737
+ at_s = None
12738
+ window_s = 5.0
12739
+ since_last = False
12740
+ last_window = None
12741
+
12742
+ args_list = list(args)
12743
+ i = 0
12744
+ while i < len(args_list):
12745
+ a = args_list[i]
12746
+ if a == '--at' and i + 1 < len(args_list):
12747
+ at_s = _watch_parse_timecode(args_list[i + 1])
12748
+ i += 2
12749
+ elif a.startswith('--at='):
12750
+ at_s = _watch_parse_timecode(a.split('=', 1)[1])
12751
+ i += 1
12752
+ elif a == '--window' and i + 1 < len(args_list):
12753
+ window_s = _watch_parse_window(args_list[i + 1])
12754
+ i += 2
12755
+ elif a.startswith('--window='):
12756
+ window_s = _watch_parse_window(a.split('=', 1)[1])
12757
+ i += 1
12758
+ elif a == '--max' and i + 1 < len(args_list):
12759
+ try:
12760
+ max_frames = max(1, min(64, int(args_list[i + 1])))
12761
+ except ValueError:
12762
+ pass
12763
+ i += 2
12764
+ elif a.startswith('--max='):
12765
+ try:
12766
+ max_frames = max(1, min(64, int(a.split('=', 1)[1])))
12767
+ except ValueError:
12768
+ pass
12769
+ i += 1
12770
+ elif a == '--since-last':
12771
+ since_last = True
12772
+ i += 1
12773
+ elif a == '--last' and i + 1 < len(args_list):
12774
+ last_window = _watch_parse_window(args_list[i + 1])
12775
+ i += 2
12776
+ elif a.startswith('--last='):
12777
+ last_window = _watch_parse_window(a.split('=', 1)[1])
12778
+ i += 1
12779
+ else:
12780
+ i += 1
12781
+
12782
+ frames = _watch_list_frames()
12783
+ if not frames:
12784
+ print(json.dumps({'frames': [], 'count': 0, 'duration_s': 0}))
12785
+ return
12786
+
12787
+ sc_start_ms = state.get('screencast_started_at_ms') or state.get('started_at_ms') or frames[0][0]
12788
+ selected = []
12789
+
12790
+ if since_last:
12791
+ last_q = 0
12792
+ try:
12793
+ with open(_watch_last_query_path()) as f:
12794
+ last_q = int(json.load(f).get('ts_ms') or 0)
12795
+ except (OSError, ValueError):
12796
+ last_q = 0
12797
+ selected = [(ts, p) for ts, p in frames if ts > last_q]
12798
+ elif last_window is not None:
12799
+ newest_ts = frames[-1][0]
12800
+ lo_ms = newest_ts - int(last_window * 1000)
12801
+ selected = [(ts, p) for ts, p in frames if ts >= lo_ms]
12802
+ elif at_s is not None:
12803
+ # Map video time to wall-clock ms
12804
+ center_ms = sc_start_ms + int(at_s * 1000)
12805
+ half_ms = int((window_s / 2.0) * 1000)
12806
+ lo_ms = center_ms - half_ms
12807
+ hi_ms = center_ms + half_ms
12808
+ selected = [(ts, p) for ts, p in frames if lo_ms <= ts <= hi_ms]
12809
+ else:
12810
+ # Default: the latest <window_s> seconds
12811
+ newest_ts = frames[-1][0]
12812
+ lo_ms = newest_ts - int(window_s * 1000)
12813
+ selected = [(ts, p) for ts, p in frames if ts >= lo_ms]
12814
+
12815
+ # Downsample to max_frames (uniform pick, keep first + last)
12816
+ if len(selected) > max_frames:
12817
+ # Try motion-aware picking when PIL is available
12818
+ motion_scores = []
12819
+ for j in range(1, len(selected)):
12820
+ d = _watch_frame_diff(selected[j - 1][1], selected[j][1])
12821
+ motion_scores.append(d if d is not None else 0.0)
12822
+ if motion_scores and any(s is not None for s in motion_scores):
12823
+ # Pick frames around the highest-motion peaks
12824
+ indices = sorted(range(len(motion_scores)),
12825
+ key=lambda k: motion_scores[k], reverse=True)
12826
+ pick_idx = sorted(set([0, len(selected) - 1] + indices[:max_frames - 2]))
12827
+ pick_idx = pick_idx[:max_frames]
12828
+ selected = [selected[k] for k in pick_idx]
12829
+ else:
12830
+ # Uniform downsample
12831
+ step = len(selected) / max_frames
12832
+ picks = [int(j * step) for j in range(max_frames)]
12833
+ picks[-1] = len(selected) - 1
12834
+ selected = [selected[k] for k in picks]
12835
+
12836
+ if selected:
12837
+ try:
12838
+ with open(_watch_last_query_path(), 'w') as f:
12839
+ json.dump({'ts_ms': selected[-1][0]}, f)
12840
+ except OSError:
12841
+ pass
12842
+
12843
+ duration_s = (selected[-1][0] - selected[0][0]) / 1000.0 if len(selected) >= 2 else 0.0
12844
+ out = {
12845
+ 'frames': [p for _, p in selected],
12846
+ 'timestamps_ms': [t for t, _ in selected],
12847
+ 'count': len(selected),
12848
+ 'duration_s': round(duration_s, 3),
12849
+ 'window_s': window_s,
12850
+ 'at_s': at_s,
12851
+ 'screencast_started_at_ms': sc_start_ms,
12852
+ }
12853
+ print(json.dumps(out, indent=2))
12854
+
12855
+
12856
+ def cmd_watch_ask(*args):
12857
+ """Tiny natural-language wrapper around `watch query`.
12858
+
12859
+ cdpilot watch ask "0:35'te kedi sola mı sağa mı koştu?"
12860
+ cdpilot watch ask "son 5 saniyede ne oldu"
12861
+
12862
+ We don't actually do LLM inference here — this command just parses a
12863
+ time window out of the question and emits the same JSON shape as
12864
+ `watch query` so the orchestrator (Claude) can pick up the frames and
12865
+ feed them to a multimodal API itself.
12866
+ """
12867
+ if not args:
12868
+ print(json.dumps({'error': 'question required'}))
12869
+ sys.exit(1)
12870
+ question = ' '.join(args)
12871
+ q_lower = question.lower()
12872
+
12873
+ # Find mm:ss or m:ss anywhere in the string
12874
+ m = _re.search(r'(\d+):(\d{1,2})(?:\.\d+)?', question)
12875
+ at_s = None
12876
+ if m:
12877
+ at_s = float(m.group(1)) * 60 + float(m.group(2))
12878
+
12879
+ # "son N saniye(de)" / "last N seconds"
12880
+ last_match = _re.search(r'(?:son|last)\s+(\d+(?:\.\d+)?)\s*(?:saniye|sec|seconds?|s\b)', q_lower)
12881
+ last_window = float(last_match.group(1)) if last_match else None
12882
+
12883
+ forward = []
12884
+ if at_s is not None:
12885
+ forward = ['--at', f'{int(at_s // 60)}:{at_s % 60:.2f}'.rstrip('0').rstrip('.'),
12886
+ '--window', '5s']
12887
+ elif last_window is not None:
12888
+ forward = ['--last', f'{last_window}s']
12889
+ else:
12890
+ forward = ['--last', '5s']
12891
+
12892
+ forward += ['--max', '8']
12893
+ cmd_watch_query(*forward)
12894
+
12895
+
12896
+ def _dispatch_watch_cmd(args):
12897
+ """Parse `cdpilot watch <sub> ...` and dispatch. Sync wrapper."""
12898
+ sub = args[0] if args else ''
12899
+ rest = args[1:]
12900
+ if not sub or sub in ('--help', '-h', 'help'):
12901
+ print("Usage: cdpilot watch <subcommand> [options]", file=sys.stderr)
12902
+ print("", file=sys.stderr)
12903
+ print("Subcommands:", file=sys.stderr)
12904
+ print(" start [url|-] [--fps=N] [--quality=N] [--max-width=N]", file=sys.stderr)
12905
+ print(" [--retention=SEC] [--disk-cap=MB] [--seek=MM:SS]", file=sys.stderr)
12906
+ print(" stop [--keep-frames]", file=sys.stderr)
12907
+ print(" query --at MM:SS --window 5s [--max=16]", file=sys.stderr)
12908
+ print(" query --since-last [--max=16]", file=sys.stderr)
12909
+ print(" query --last 5s [--max=16]", file=sys.stderr)
12910
+ print(" status", file=sys.stderr)
12911
+ print(" ask \"<natural-language question>\"", file=sys.stderr)
12912
+ sys.exit(0)
12913
+ if sub == 'start':
12914
+ cmd_watch_start(*rest)
12915
+ elif sub == 'stop':
12916
+ cmd_watch_stop(*rest)
12917
+ elif sub == 'query':
12918
+ cmd_watch_query(*rest)
12919
+ elif sub == 'status':
12920
+ cmd_watch_status(*rest)
12921
+ elif sub == 'ask':
12922
+ cmd_watch_ask(*rest)
12923
+ else:
12924
+ print(f"Unknown watch subcommand: {sub}. Run: cdpilot watch --help", file=sys.stderr)
12925
+ sys.exit(1)
12926
+
12927
+
12928
+ # ─── End cdpilot watch namespace ──────────────────────────────────────────────
12929
+
12930
+
10412
12931
  # ─── CLI ───
10413
12932
 
10414
12933
  if __name__ == "__main__":
@@ -10428,15 +12947,28 @@ if __name__ == "__main__":
10428
12947
  print(__doc__)
10429
12948
  sys.exit(0)
10430
12949
 
12950
+ # Hidden re-entrant entry for the watch daemon. `cdpilot watch start`
12951
+ # forks this process with the flag so the screencast consumer runs in
12952
+ # the background while the foreground process returns immediately.
12953
+ if cmd == WATCH_DAEMON_FLAG:
12954
+ _cmd_watch_daemon_entry()
12955
+ sys.exit(0)
12956
+
10431
12957
  sync_cmds = {
10432
12958
  'launch': cmd_launch,
10433
- 'tabs': cmd_tabs,
12959
+ 'tabs': lambda: cmd_tabs(
12960
+ reap='--reap' in args,
12961
+ max_tabs=next((int(a.split('=')[1]) for a in args
12962
+ if a.startswith('--max=') and a.split('=')[1].isdigit()), 1),
12963
+ include_real='--all' in args,
12964
+ dry_run='--dry-run' in args),
10434
12965
  'extensions': cmd_extensions,
10435
12966
  'stop': cmd_stop,
10436
12967
  'version': cmd_version,
10437
12968
  'proxy': lambda: cmd_proxy(*args),
10438
12969
  'headless': lambda: cmd_headless(args[0] if args else None),
10439
12970
  'stealth': lambda: cmd_stealth(args[0] if args else None),
12971
+ 'mode': lambda: cmd_mode(args[0] if args else None),
10440
12972
  'block': lambda: cmd_block(*args),
10441
12973
  'context': lambda: cmd_context(*args),
10442
12974
  'show': lambda: cmd_show(args[0] if args else None),
@@ -10458,6 +12990,7 @@ if __name__ == "__main__":
10458
12990
  'test': lambda: cmd_test_dispatch(args),
10459
12991
  'trace': lambda: cmd_trace_dispatch(args),
10460
12992
  'blog': lambda: _dispatch_blog_cmd(args),
12993
+ 'watch': lambda: _dispatch_watch_cmd(args),
10461
12994
  }
10462
12995
 
10463
12996
  if cmd == "serve":
@@ -10497,8 +13030,18 @@ if __name__ == "__main__":
10497
13030
  cmd_switch_tab(args[0])
10498
13031
  sys.exit(0)
10499
13032
 
13033
+ # `stop --smart` aliases the smart close (owned tabs + browser-if-empty).
13034
+ # Bare `stop` keeps its legacy full-kill behavior for backward compatibility.
13035
+ if cmd == "stop" and "--smart" in args:
13036
+ asyncio.run(cmd_close(
13037
+ force_browser="--force" in args,
13038
+ keep_browser=("--keep" in args or "--keep-browser" in args),
13039
+ ))
13040
+ sys.exit(0)
13041
+
10500
13042
  if cmd in sync_cmds:
10501
13043
  sync_cmds[cmd]()
13044
+ _enforce_tab_budget()
10502
13045
  sys.exit(0)
10503
13046
 
10504
13047
  def require_args(n, usage):
@@ -10557,7 +13100,10 @@ if __name__ == "__main__":
10557
13100
  "emulate": lambda: (require_args(1, "emulate <device>"), None)[1] if not args else cmd_emulate(args[0]),
10558
13101
  "glow": lambda: cmd_glow(args[0] if args else "on"),
10559
13102
  "debug": lambda: cmd_debug(args[0] if args else None),
10560
- "close": cmd_close,
13103
+ "close": lambda: cmd_close(
13104
+ force_browser="--force" in args,
13105
+ keep_browser=("--keep" in args or "--keep-browser" in args),
13106
+ ),
10561
13107
  'new-tab': lambda: cmd_new_tab(args[0] if args else 'about:blank'),
10562
13108
  'close-tab': lambda: cmd_close_tab(args[0] if args else None),
10563
13109
  'pdf': lambda: cmd_pdf(args[0] if args else None),
@@ -10614,6 +13160,15 @@ if __name__ == "__main__":
10614
13160
  'captcha-check': cmd_captcha_check,
10615
13161
  'captcha-wait': lambda: cmd_captcha_wait(args[0] if args else None),
10616
13162
  'captcha': lambda: cmd_captcha_dispatch(args),
13163
+ 'friction': cmd_friction,
13164
+ 'captcha-solve': lambda: cmd_captcha_solve(
13165
+ next((a.split('=')[1] for a in args if a.startswith('--provider=')),
13166
+ args[args.index('--provider') + 1] if '--provider' in args and args.index('--provider') + 1 < len(args)
13167
+ else (next((a for a in args if not a.startswith('--')), None))),
13168
+ ),
13169
+ 'profile': lambda: cmd_profile_dispatch(args),
13170
+ 'press-hold': lambda: cmd_press_hold(
13171
+ next((a for a in args if not a.startswith('--')), None)),
10617
13172
  'agent': lambda: _dispatch_agent_cmd(args),
10618
13173
  }
10619
13174
 
@@ -10621,7 +13176,8 @@ if __name__ == "__main__":
10621
13176
  NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
10622
13177
  'dialog', 'download', 'throttle', 'permission', 'intercept',
10623
13178
  'batch', 'screenshot-diff', 'run',
10624
- 'captcha-check', 'captcha-wait', 'captcha',
13179
+ 'captcha-check', 'captcha-wait', 'captcha', 'friction',
13180
+ 'profile',
10625
13181
  'cookies'} # v0.6.1: cookies auto-config doesn't need browser
10626
13182
  # Clean up idle sessions before running any command
10627
13183
  _cleanup_idle_sessions()
@@ -10630,6 +13186,7 @@ if __name__ == "__main__":
10630
13186
  if cmd in NO_CONTROL_CMDS:
10631
13187
  asyncio.run(async_map[cmd]())
10632
13188
  _update_session_timestamp()
13189
+ _enforce_tab_budget()
10633
13190
  else:
10634
13191
  async def _wrapped():
10635
13192
  ws_url = None
@@ -10650,6 +13207,7 @@ if __name__ == "__main__":
10650
13207
  await _control_end(ws_url)
10651
13208
  asyncio.run(_wrapped())
10652
13209
  _update_session_timestamp()
13210
+ _enforce_tab_budget()
10653
13211
  else:
10654
13212
  print(f"Unknown command: {cmd}", file=sys.stderr)
10655
13213
  all_cmds = sorted(set(list(sync_cmds.keys()) + list(async_map.keys())))