patchright-cli 0.11.0__py3-none-any.whl → 0.11.1__py3-none-any.whl

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.
@@ -1,3 +1,3 @@
1
1
  """patchright-cli — Undetected browser automation CLI using Patchright."""
2
2
 
3
- __version__ = "0.11.0"
3
+ __version__ = "0.11.1"
patchright_cli/daemon.py CHANGED
@@ -30,7 +30,7 @@ from urllib.parse import unquote, urlparse
30
30
 
31
31
  from patchright_cli import daemon_pid, snapshot_text, values
32
32
  from patchright_cli.debugger import SessionDebugger
33
- from patchright_cli.snapshot import EMPTY_SNAPSHOT, ai_snapshot, probe_ref, save_snapshot
33
+ from patchright_cli.snapshot import EMPTY_SNAPSHOT, ai_snapshot, is_target_closed, probe_ref, save_snapshot
34
34
  from patchright_cli.snapshot_text import SnapshotIndex, has_ref, normalize_ref, render_hits
35
35
 
36
36
  logger = logging.getLogger("patchright-cli.daemon")
@@ -106,6 +106,10 @@ class Tab:
106
106
  self.history: list[str] = []
107
107
  self.history_index: int = -1
108
108
  self.codegen: list[str] | None = None
109
+ # Its page closed or crashed outside the daemon. The tab stays until the
110
+ # next command reports it as not open, so a command still running on it
111
+ # never falls back to the default tab's page.
112
+ self.gone = False
109
113
 
110
114
 
111
115
  class Session:
@@ -147,6 +151,16 @@ class Session:
147
151
  # True when `open --webmcp` launched Chrome with the WebMCP feature on.
148
152
  self.webmcp = False
149
153
  self.current_tab: int = 0
154
+ # The default tab's page closed or crashed outside the daemon. Its index
155
+ # would otherwise slide onto a neighbour, and the next command would run
156
+ # on someone else's tab. Cleared only by select_page.
157
+ self._current_gone = False
158
+ # Named tabs being opened right now. Their pages arrive through the
159
+ # context's page event like any other; they must not become the
160
+ # default tab's page.
161
+ self._opening_named = 0
162
+ # A renderer crash leaves page.is_closed() False, so crashes are remembered here.
163
+ self._crashed: weakref.WeakSet = weakref.WeakSet()
150
164
  self.tabs: dict[str, Tab] = {DEFAULT_TAB: Tab(DEFAULT_TAB)}
151
165
  # Whether anyone actually works in the implicit default tab. A session
152
166
  # opened only through `--tab` never touches it, and an untouched default
@@ -207,10 +221,35 @@ class Session:
207
221
  async def setup_listeners(self):
208
222
  """Attach network and dialog listeners to all existing pages."""
209
223
  for page in self.pages:
224
+ self._watch_page(page)
210
225
  await self._attach_page_listeners(page)
211
226
  self.context.on("page", lambda page: asyncio.ensure_future(self._on_new_page(page)))
212
227
 
228
+ def _watch_page(self, page) -> None:
229
+ # Chrome reports pages a person, the page or the Host closed. A crashed
230
+ # page is gone too: Patchright fails every later call on it, reload
231
+ # included, so it is closed when the session may close it.
232
+ page.on("close", lambda *_: self._forget_page(page, outside=True))
233
+ page.on("crash", lambda *_: self._on_crash(page))
234
+
235
+ def _is_dead(self, page) -> bool:
236
+ return page.is_closed() or page in self._crashed
237
+
238
+ def _on_crash(self, page) -> None:
239
+ self._crashed.add(page)
240
+ self._forget_page(page, outside=True)
241
+ if self.may_close_page(page):
242
+ asyncio.ensure_future(self._close_quietly(page))
243
+
244
+ async def _close_quietly(self, page) -> None:
245
+ with contextlib.suppress(Exception):
246
+ await self._unattended(page.close())
247
+
213
248
  async def _on_new_page(self, page):
249
+ # Before any await: a popup can close (or crash) while this runs.
250
+ self._watch_page(page)
251
+ if self._is_dead(page):
252
+ return
214
253
  if self.uses_host_context and page not in self.own_pages:
215
254
  try:
216
255
  opener = await page.opener()
@@ -218,9 +257,19 @@ class Session:
218
257
  opener = None
219
258
  if opener is not None and opener in self.own_pages:
220
259
  self.own_pages.add(page)
260
+ if self._is_dead(page):
261
+ if page in self._crashed and self.may_close_page(page):
262
+ # Ownership is only known now, after the crash handler ran.
263
+ asyncio.ensure_future(self._close_quietly(page))
264
+ return
221
265
  if page not in self.pages:
222
- self.pages.append(page)
223
- self.current_tab = len(self.pages) - 1
266
+ # A named tab's page belongs to that tab. While the default tab's
267
+ # page is gone, a new page came from some other tab. Taking either
268
+ # would put the default caller on someone else's tab.
269
+ if self._current_gone or self._opening_named:
270
+ self.pages.append(page)
271
+ else:
272
+ self.select_page(page)
224
273
  # Emulate before the listener setup awaits, so a popup's own scripts
225
274
  # see the session's media features as early as possible.
226
275
  await self.apply_emulation(page)
@@ -392,7 +441,7 @@ class Session:
392
441
  pinned = self.active_tab.page
393
442
  if pinned is not None:
394
443
  return pinned
395
- if not self.pages:
444
+ if self._current_gone or not self.pages:
396
445
  return None
397
446
  idx = max(0, min(self.current_tab, len(self.pages) - 1))
398
447
  return self.pages[idx]
@@ -448,14 +497,14 @@ class Session:
448
497
  whoever activated last owned every post-await write, so a command that
449
498
  suspended came back storing its refs and history on someone else's tab.
450
499
 
451
- The binding carries the session name so a tab name cannot be honoured
452
- against a different session that happens to use the same name.
500
+ The binding carries the Session itself, so a tab cannot be honoured
501
+ against another session, a reopened one of the same name included. It holds
502
+ the Tab itself, not its name: when the tab is dropped or its name reused
503
+ mid-command, the command keeps working on the tab it started on.
453
504
  """
454
505
  bound = _ACTIVE_TAB.get()
455
- if bound is not None and bound[0] == self.name:
456
- tab = self.tabs.get(bound[1])
457
- if tab is not None:
458
- return tab
506
+ if bound is not None and bound[0] is self:
507
+ return bound[1]
459
508
  return self._ensure_default_tab()
460
509
 
461
510
  def _ensure_default_tab(self) -> Tab:
@@ -469,23 +518,26 @@ class Session:
469
518
  def activate_tab(self, name: str) -> Tab:
470
519
  """Route this task's state access to `name`. Raises KeyError if unknown."""
471
520
  tab = self.tabs[name]
472
- _ACTIVE_TAB.set((self.name, name))
521
+ _ACTIVE_TAB.set((self, tab))
473
522
  return tab
474
523
 
475
524
  async def open_tab(self, name: str) -> Tab:
476
525
  """Return the named tab, giving it a fresh page on first use."""
477
526
  existing = self.tabs.get(name)
478
- if existing is not None:
527
+ if existing is not None and not existing.gone:
479
528
  return existing
480
- # _on_new_page advances current_tab for every page the context opens.
481
- # A named tab owns its page outright, so letting it move that pointer
482
- # would silently reassign whichever page the default caller was on.
483
- previous = self.current_tab
484
- page = await self.new_page()
485
- if page not in self.pages:
486
- self.pages.append(page)
487
- self.current_tab = min(previous, max(0, len(self.pages) - 1))
529
+ # Leaves the default tab's page alone: whatever it does meanwhile (a
530
+ # close outside, a tab-select) stands.
531
+ self._opening_named += 1
532
+ try:
533
+ page = await self.new_page()
534
+ finally:
535
+ self._opening_named -= 1
488
536
  tab = Tab(name, page)
537
+ if self._is_dead(page):
538
+ tab.gone = True
539
+ elif page not in self.pages:
540
+ self.pages.append(page)
489
541
  self.tabs[name] = tab
490
542
  return tab
491
543
 
@@ -503,16 +555,7 @@ class Session:
503
555
  tab = self.tabs.pop(name)
504
556
  page = tab.page
505
557
  if page is not None:
506
- self._console_sessions.pop(id(page), None)
507
- if page in self.pages:
508
- removed_index = self.pages.index(page)
509
- self.pages.remove(page)
510
- # Follow the page current_tab pointed at. Clamping alone silently
511
- # reassigns the default caller to its neighbour when an earlier
512
- # page is the one that went.
513
- if removed_index < self.current_tab:
514
- self.current_tab -= 1
515
- self.current_tab = max(0, min(self.current_tab, len(self.pages) - 1))
558
+ self._forget_page(page)
516
559
  try:
517
560
  await self._unattended(page.close())
518
561
  except Exception:
@@ -523,13 +566,51 @@ class Session:
523
566
  self.default_in_use = False
524
567
  self._ensure_default_tab()
525
568
 
526
- named = [key for key in self.tabs if key != DEFAULT_TAB]
569
+ named = [key for key, t in self.tabs.items() if key != DEFAULT_TAB and not t.gone]
527
570
  return bool(named) or self.default_in_use
528
571
 
572
+ def select_page(self, page) -> None:
573
+ """Make `page` the default tab's page, tracking it first if it is new."""
574
+ if self._is_dead(page):
575
+ return
576
+ if page not in self.pages:
577
+ self.pages.append(page)
578
+ self.current_tab = self.pages.index(page)
579
+ self._current_gone = False
580
+
581
+ def _forget_page(self, page, *, outside: bool = False) -> None:
582
+ """Stop tracking `page`. Every removal goes through here; idempotent.
583
+
584
+ `outside` means Chrome reported it closed or crashed, not that a command
585
+ closed it: then the default tab does not move on to another page, and a
586
+ named tab on it is marked gone.
587
+ """
588
+ self._console_sessions.pop(id(page), None)
589
+ if outside:
590
+ for tab in self.tabs.values():
591
+ if tab.page is page:
592
+ tab.gone = True
593
+ if page not in self.pages:
594
+ return
595
+ removed_index = self.pages.index(page)
596
+ self.pages.remove(page)
597
+ if outside and removed_index == self.current_tab:
598
+ self._current_gone = True
599
+ # Follow the page current_tab pointed at. Clamping alone silently
600
+ # reassigns the default caller to its neighbour when an earlier page
601
+ # is the one that went.
602
+ elif removed_index < self.current_tab:
603
+ self.current_tab -= 1
604
+ self.current_tab = max(0, min(self.current_tab, len(self.pages) - 1))
605
+
529
606
  async def new_page(self):
530
607
  """Open a Page in this Session's context and record it as ours."""
531
608
  page = await self.context.new_page()
532
609
  self.own_pages.add(page)
610
+ if page in self._crashed:
611
+ # It crashed before it was ours, so the crash handler could not close it.
612
+ await self._close_quietly(page)
613
+ return page
533
614
  # The context's "page" handler applies this too, but runs as a task;
534
615
  # applying here means the caller never navigates an unemulated page.
535
616
  await self.apply_emulation(page)
@@ -1112,10 +1193,16 @@ class DaemonState:
1112
1193
  return time.monotonic() - self.last_activity > self.idle_timeout
1113
1194
 
1114
1195
  async def get_or_create_session(self, name: str = "default", **kwargs) -> Session:
1115
- if kwargs.get("cdp_endpoint") and kwargs.get("context_mode") == "host":
1116
- async with self._host_attach_lock:
1117
- return await self._get_or_create_session(name, **kwargs)
1118
- return await self._get_or_create_session(name, **kwargs)
1196
+ try:
1197
+ if kwargs.get("cdp_endpoint") and kwargs.get("context_mode") == "host":
1198
+ async with self._host_attach_lock:
1199
+ return await self._get_or_create_session(name, **kwargs)
1200
+ return await self._get_or_create_session(name, **kwargs)
1201
+ except Exception as exc:
1202
+ # A session that never got registered was never open, so there is no target to recover.
1203
+ if is_target_closed(exc) and name not in self.sessions:
1204
+ raise RuntimeError(f"Chrome closed while opening session '{name}': {exc}") from exc
1205
+ raise
1119
1206
 
1120
1207
  async def _get_or_create_session(
1121
1208
  self,
@@ -1515,6 +1602,8 @@ async def _page_info(session: Session, cwd: str | None = None) -> dict:
1515
1602
  """Return standard page info + snapshot after a state-changing command."""
1516
1603
  page = session.page
1517
1604
  if page is None:
1605
+ if session.pages:
1606
+ return {"success": True, "output": "### Page\n- The page closed after the command."}
1518
1607
  return {"success": True, "output": "No page open."}
1519
1608
 
1520
1609
  url = page.url
@@ -1523,7 +1612,14 @@ async def _page_info(session: Session, cwd: str | None = None) -> dict:
1523
1612
  except Exception:
1524
1613
  title = ""
1525
1614
 
1526
- snap_path = save_snapshot(_printed(await _ai_snapshot(session, page)), cwd)
1615
+ try:
1616
+ snap = await _ai_snapshot(session, page)
1617
+ except Exception as exc:
1618
+ if not is_target_closed(exc):
1619
+ raise
1620
+ # The command's own action closed the page (a popup's "Done" button). It ran, so this is no failure.
1621
+ return {"success": True, "output": f"### Page\n- Page URL: {url}\n- The page closed after the command."}
1622
+ snap_path = save_snapshot(_printed(snap), cwd)
1527
1623
 
1528
1624
  output_lines = [
1529
1625
  "### Page",
@@ -1791,9 +1887,20 @@ def _error_message(cmd: str, args: list, options: dict, exc: Exception) -> tuple
1791
1887
  return message, []
1792
1888
 
1793
1889
 
1890
+ # Stable `code` values on failed replies (docs/daemon-protocol.md). Clients branch
1891
+ # on these instead of on `output`, whose wording can change in any release.
1892
+ SESSION_NOT_OPEN = "session_not_open"
1893
+ TAB_NOT_OPEN = "tab_not_open"
1894
+ TARGET_CLOSED = "target_closed"
1895
+ NO_PAGE = "no_page"
1896
+
1897
+
1794
1898
  def _error_reply(cmd: str, args: list, options: dict, exc: Exception, *extra: str) -> dict:
1795
1899
  message, helps = _error_message(cmd, args, options, exc)
1796
- return {"success": False, "output": "\n".join([f"Error: {message}", *extra, *helps])}
1900
+ reply = {"success": False, "output": "\n".join([f"Error: {message}", *extra, *helps])}
1901
+ if is_target_closed(exc):
1902
+ reply["code"] = TARGET_CLOSED
1903
+ return reply
1797
1904
 
1798
1905
 
1799
1906
  @register("click")
@@ -1969,6 +2076,8 @@ async def cmd_snapshot(session: Session, page, args: list, options: dict, cwd: s
1969
2076
  try:
1970
2077
  matches = await scoped.count()
1971
2078
  except Exception as exc:
2079
+ if is_target_closed(exc):
2080
+ raise
1972
2081
  return {"success": False, "output": f"Invalid selector {selector!r}: {exc}"}
1973
2082
  if not matches:
1974
2083
  return {"success": False, "output": f"No element matches {selector!r} on this page."}
@@ -2068,6 +2177,8 @@ async def _find_by_role(session: Session, page, role: str, options: dict, limit:
2068
2177
  try:
2069
2178
  total = await locator.count()
2070
2179
  except Exception as exc:
2180
+ if is_target_closed(exc):
2181
+ raise
2071
2182
  return {"success": False, "output": f"find {query} failed: {exc}"}
2072
2183
  if not total:
2073
2184
  return {
@@ -2499,6 +2610,8 @@ async def cmd_html(session: Session, page, args: list, options: dict, cwd: str |
2499
2610
  try:
2500
2611
  matches = await scoped.count()
2501
2612
  except Exception as exc:
2613
+ if is_target_closed(exc):
2614
+ raise
2502
2615
  return {"success": False, "output": f"Invalid selector {selector!r}: {exc}"}
2503
2616
  if matches != 1:
2504
2617
  if not matches:
@@ -2634,6 +2747,7 @@ async def cmd_tab_new(session: Session, page, args: list, options: dict, cwd: st
2634
2747
  url = args[0] if args else "about:blank"
2635
2748
  await session.check_navigation(url, _web_only(options))
2636
2749
  new_page = await session.new_page()
2750
+ session.select_page(new_page)
2637
2751
  if url and url != "about:blank":
2638
2752
  await session.goto(new_page, url, _web_only(options))
2639
2753
  return await _page_info(session, cwd)
@@ -2641,6 +2755,11 @@ async def cmd_tab_new(session: Session, page, args: list, options: dict, cwd: st
2641
2755
 
2642
2756
  @register("tab-close")
2643
2757
  async def cmd_tab_close(session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState) -> dict:
2758
+ if not session.pages:
2759
+ return _no_page_reply()
2760
+ if not args and session.page is None:
2761
+ # current_tab already points at a neighbour; closing it would close someone else's tab.
2762
+ return _gone_reply()
2644
2763
  idx = int(args[0]) if args else session.current_tab
2645
2764
  if not (0 <= idx < len(session.pages)):
2646
2765
  return {"success": False, "output": f"Invalid tab index: {idx}"}
@@ -2658,11 +2777,7 @@ async def cmd_tab_close(session: Session, page, args: list, options: dict, cwd:
2658
2777
  await session.close_tab(owner)
2659
2778
  return {"success": True, "output": f"Tab {idx} closed (was tab '{owner}')."}
2660
2779
 
2661
- session.pages.pop(idx)
2662
- session._console_sessions.pop(id(page), None)
2663
- if idx < session.current_tab:
2664
- session.current_tab -= 1
2665
- session.current_tab = max(0, min(session.current_tab, len(session.pages) - 1))
2780
+ session._forget_page(page)
2666
2781
  await page.close()
2667
2782
  return {"success": True, "output": f"Tab {idx} closed."}
2668
2783
 
@@ -2683,7 +2798,7 @@ async def cmd_tab_select(
2683
2798
  }
2684
2799
  idx = int(args[0])
2685
2800
  if 0 <= idx < len(session.pages):
2686
- session.current_tab = idx
2801
+ session.select_page(session.pages[idx])
2687
2802
  await session.pages[idx].bring_to_front()
2688
2803
  return await _page_info(session, cwd)
2689
2804
  return {"success": False, "output": f"Invalid tab index: {idx}"}
@@ -4089,6 +4204,8 @@ _WEBMCP_UNTRUSTED = "Tool names, descriptions, schemas and results are page-prov
4089
4204
 
4090
4205
  def _webmcp_error(exc: Exception) -> dict:
4091
4206
  """A WebMCP failure as a reply: Playwright's first line, without its `WebMCP.tools: ` prefix."""
4207
+ if is_target_closed(exc):
4208
+ raise exc
4092
4209
  message = str(exc).strip().splitlines()[0] if str(exc).strip() else type(exc).__name__
4093
4210
  message = re.sub(r"^WebMCP\.\w+: ", "", message)
4094
4211
  if "WebMCP is not enabled" in message:
@@ -4274,6 +4391,30 @@ async def cmd_debugger_status(
4274
4391
 
4275
4392
  # Commands that end a Session run its teardown unattended (see SessionDebugger),
4276
4393
  # so they must not be held at a pause themselves.
4394
+ def _no_page_reply() -> dict:
4395
+ """The session is open but has no pages left."""
4396
+ return {
4397
+ "success": False,
4398
+ "output": "No page open. Run 'tab-new' to create one, or 'close' and 'open' again.",
4399
+ "code": NO_PAGE,
4400
+ }
4401
+
4402
+
4403
+ def _gone_reply() -> dict:
4404
+ """The default tab's page closed or crashed outside the daemon, and no page took its place."""
4405
+ return {
4406
+ "success": False,
4407
+ "output": (
4408
+ "This tab's page closed or crashed outside the daemon.\n"
4409
+ "help: run `tab-list`, then `tab-select <index>`, or `tab-new [url]`"
4410
+ ),
4411
+ "code": TARGET_CLOSED,
4412
+ }
4413
+
4414
+
4415
+ # Commands that run without a current page: the tab ones get a page back, and
4416
+ # console reads the session-wide buffer, which outlives the page that closed.
4417
+ _PAGELESS = frozenset({"tab-new", "tab-select", "tab-list", "tab-close", "console"})
4277
4418
  _NOT_HELD = {"close", "close-all", "kill-all", "detach", "delete-data", "list", "profile-list"}
4278
4419
 
4279
4420
 
@@ -4368,6 +4509,8 @@ async def _dispatch_command(state: DaemonState, msg: dict) -> dict:
4368
4509
  session.default_in_use = True
4369
4510
  session._ensure_default_tab()
4370
4511
  session.activate_tab(tab_name)
4512
+ if session.page is None and session.pages:
4513
+ return _gone_reply()
4371
4514
  if session.page:
4372
4515
  session.push_history(session.page.url)
4373
4516
  return await _page_info(session, cwd)
@@ -4482,6 +4625,7 @@ async def _dispatch_command(state: DaemonState, msg: dict) -> dict:
4482
4625
  return {
4483
4626
  "success": False,
4484
4627
  "output": f"Session '{session_name}' is not open. Run 'open' first.",
4628
+ "code": SESSION_NOT_OPEN,
4485
4629
  }
4486
4630
 
4487
4631
  page = session.page
@@ -4534,6 +4678,8 @@ async def _dispatch_command(state: DaemonState, msg: dict) -> dict:
4534
4678
  # Route to the caller's tab last, so it cannot shadow an unknown command
4535
4679
  # or the page-less commands above, and re-read the page afterwards --
4536
4680
  # activation is what decides which page `session.page` resolves to.
4681
+ if tab_name in session.tabs and session.tabs[tab_name].gone is True:
4682
+ del session.tabs[tab_name]
4537
4683
  if tab_name not in session.tabs:
4538
4684
  return {
4539
4685
  "success": False,
@@ -4541,19 +4687,19 @@ async def _dispatch_command(state: DaemonState, msg: dict) -> dict:
4541
4687
  f"Tab '{tab_name}' is not open in session '{session_name}'. "
4542
4688
  f"Run `patchright-cli --tab {tab_name} open <url>` first."
4543
4689
  ),
4690
+ "code": TAB_NOT_OPEN,
4544
4691
  }
4545
4692
  session.activate_tab(tab_name)
4546
4693
  page = session.page
4547
4694
 
4548
- if page is None:
4549
- return {
4550
- "success": False,
4551
- "output": "No page open. Run 'tab-new' to create one, or 'close' and 'open' again.",
4552
- }
4553
-
4554
- await _apply_timeouts(page, options)
4695
+ if page is None and session.pages and cmd not in _PAGELESS:
4696
+ return _gone_reply()
4697
+ if page is None and cmd not in _PAGELESS:
4698
+ return _no_page_reply()
4555
4699
 
4556
- await _draw_action_callout(session, page, cmd, args)
4700
+ if page is not None:
4701
+ await _apply_timeouts(page, options)
4702
+ await _draw_action_callout(session, page, cmd, args)
4557
4703
  result = await handler(session, page, args, options, cwd, state)
4558
4704
  if result.get("success") and session._codegen is not None and cmd in _CODEGEN_RECORDABLE:
4559
4705
  args_quoted = [f'"{a}"' if " " in a else a for a in args]
@@ -24,6 +24,8 @@ from fastmcp.tools import ToolResult
24
24
  from mcp.types import ImageContent
25
25
  from pydantic import Field
26
26
 
27
+ from patchright_cli import values
28
+
27
29
  CLI = (sys.executable, "-m", "patchright_cli")
28
30
  DAEMON_PORT = int(os.environ.get("PATCHRIGHT_CLI_MCP_DAEMON_PORT", "9321"))
29
31
  # File paths given to tools must sit beneath MCP_ROOT, so an MCP client (possibly
@@ -220,6 +222,7 @@ TOOL_SPECS = {
220
222
  "description": "CLI command: snapshot Capture an accessibility snapshot and refresh element references.",
221
223
  "params": [
222
224
  ("ref", "string", False, "", "Optional element reference for a subtree."),
225
+ ("selector", "string", False, "", "Optional CSS selector of the one element to print; not with ref."),
223
226
  ("filename", "string", False, "", "Optional output file."),
224
227
  ("depth", "number", False, 0, "Maximum snapshot depth; zero means unlimited."),
225
228
  ("interactive", "boolean", False, False, "Return interactive elements only."),
@@ -286,7 +289,8 @@ TOOL_SPECS = {
286
289
  ],
287
290
  },
288
291
  "patchright_drop": {
289
- "description": "CLI command: drop Drop a file or data payload onto an element.",
292
+ "description": "CLI command: drop Drop a file or data payload onto an element. Fails when the target does not "
293
+ "accept drops (its dragover handler must call preventDefault()).",
290
294
  "params": [
291
295
  ("ref", "string", True, None, "Target element reference."),
292
296
  ("path", "string", False, "", "File path."),
@@ -794,9 +798,16 @@ TOOL_SPECS = {
794
798
  ],
795
799
  },
796
800
  "patchright_console": {
797
- "description": "CLI command: console Return captured browser console messages.",
801
+ "description": "CLI command: console Return captured browser console messages. The first call starts "
802
+ "session-wide CDP capture, which pages can detect; messages from before it are unavailable.",
798
803
  "params": [
799
- ("level", "string", False, "", "Optional level: debug, info, log, warning or error."),
804
+ (
805
+ "level",
806
+ "string",
807
+ False,
808
+ None,
809
+ "Level to filter by, e.g. error, warning, info or log; empty for every level.",
810
+ ),
800
811
  ("clear", "boolean", False, False, "Clear messages after reading."),
801
812
  ("session", "string", False, "default", "Named Patchright browser session."),
802
813
  ],
@@ -840,7 +851,8 @@ TOOL_SPECS = {
840
851
  ],
841
852
  },
842
853
  "patchright_highlight": {
843
- "description": "CLI command: highlight Show, style or hide a page highlight overlay.",
854
+ "description": "CLI command: highlight Show, style or hide a page highlight overlay. The page can detect the "
855
+ "overlay while it is shown; hiding the last highlight removes it.",
844
856
  "params": [
845
857
  ("ref", "string", False, "", "Element reference; optional with hide=true, which then clears all."),
846
858
  ("style", "string", False, "", "Optional CSS style."),
@@ -1260,9 +1272,13 @@ def _build_args(name: str, p: dict[str, Any]) -> list[str]:
1260
1272
 
1261
1273
  if name == "patchright_snapshot":
1262
1274
  _validate_cli_path(p["filename"])
1275
+ if p["ref"] and p["selector"]:
1276
+ raise ValueError("Give either ref or selector, not both")
1263
1277
  args = ["snapshot"]
1264
1278
  if p["ref"]:
1265
1279
  args.append(_V(p["ref"]))
1280
+ if p["selector"]:
1281
+ args.append(f"--selector={p['selector']}")
1266
1282
  if p["filename"]:
1267
1283
  args.append(f"--filename={p['filename']}")
1268
1284
  if p["depth"] != 0:
@@ -1616,6 +1632,9 @@ def _parameter_type(tool_name: str, param_name: str, type_name: str) -> Any:
1616
1632
  if tool_name == "patchright_network_state_set" and param_name == "state":
1617
1633
  return Literal["online", "offline"]
1618
1634
 
1635
+ if tool_name == "patchright_console" and param_name == "level":
1636
+ return Literal[("", *values.CONSOLE_LEVELS)] | None
1637
+
1619
1638
  if tool_name in _EMULATION_VALUES and param_name == "value":
1620
1639
  return Literal[_EMULATION_VALUES[tool_name]]
1621
1640
 
@@ -17,6 +17,16 @@ from patchright_cli.snapshot_text import REF_TAG
17
17
  EMPTY_SNAPSHOT = "# Empty page - no accessible elements found\n"
18
18
 
19
19
 
20
+ def is_target_closed(exc: Exception) -> bool:
21
+ """The page, context or browser closed under the command.
22
+
23
+ Patchright raises every driver-reported close as TargetClosedError, matched
24
+ by name because Patchright is imported lazily. Never by message: a page's
25
+ own error can say "has been closed".
26
+ """
27
+ return type(exc).__name__ == "TargetClosedError"
28
+
29
+
20
30
  async def ai_snapshot(page, boxes: bool = False) -> str:
21
31
  """The full page's AI snapshot, iframes included. Refreshes the page's ref map.
22
32
 
@@ -24,7 +34,9 @@ async def ai_snapshot(page, boxes: bool = False) -> str:
24
34
  """
25
35
  try:
26
36
  return await page.aria_snapshot(mode="ai", boxes=True if boxes else None)
27
- except Exception:
37
+ except Exception as exc:
38
+ if is_target_closed(exc):
39
+ raise
28
40
  return ""
29
41
 
30
42
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: patchright-cli
3
- Version: 0.11.0
3
+ Version: 0.11.1
4
4
  Summary: Anti-detect browser automation CLI using Patchright (undetected Playwright fork)
5
5
  Project-URL: Homepage, https://github.com/AhaiMk01/patchright-cli
6
6
  Project-URL: Repository, https://github.com/AhaiMk01/patchright-cli
@@ -1,14 +1,14 @@
1
- patchright_cli/__init__.py,sha256=NtshVcJ_ORD2FrPJ7wOevxc6vWc9xwoeqtF31-pNnjM,101
1
+ patchright_cli/__init__.py,sha256=evwPVA4jyqv1oTCJI0_5G2IRo1Q_KhgmmlrM8Tl4BvY,101
2
2
  patchright_cli/__main__.py,sha256=oP-WjCPVHGHbRJdUuh_Mp9aLHFVGIoYeL3mkAWfHuJs,130
3
3
  patchright_cli/_entry.py,sha256=cA4C63ieagnHc-XoZjiTcIgDwzQj4rlpS_gxN3QeeFs,648
4
4
  patchright_cli/agent_hooks.py,sha256=A_Z1FNHLdbei86uu2mBf2TD1QwHT8v3yxaJaLidYBbU,17745
5
5
  patchright_cli/cli.py,sha256=wfR3hhC71N9TIg0NyqDGRmzAR4d0oVpNBJ8u7cFhhJw,92393
6
- patchright_cli/daemon.py,sha256=o8zAf3vbTsAZk55VilWNYNgZQw9LJ6W4Ucqg6MS2xVM,208823
6
+ patchright_cli/daemon.py,sha256=z1JA5kSi53EsKF5zNlLmVm52QQqOMPlUUSvUamQwMJQ,215169
7
7
  patchright_cli/daemon_pid.py,sha256=oXG7dXB8cp3f_WLwGIUNwb9yC5wxHxUnUs3YTFMq4H0,6655
8
8
  patchright_cli/dashboard.py,sha256=AfonvEkjzTR0Ao3iLHSXzy54lMNU6WgEgklaXdBz_is,16317
9
9
  patchright_cli/debugger.py,sha256=x3SgsrNl7dNiaPrlB821dyVoKtAAiheBz1p-3F23ABQ,9841
10
- patchright_cli/mcp_server.py,sha256=6ZAvOX6WjLsUpJTb75qeqvftSvv_U6ZE6F2IazJx3ZU,77561
11
- patchright_cli/snapshot.py,sha256=wUkyQK4xk_vfh03_fJfpuqRoV4H2QnlUzOis8uiSIlw,1970
10
+ patchright_cli/mcp_server.py,sha256=gdNITDaXC3be9SHR9ePr4nCPHj6wI7w0rAgFB7PDC9s,78482
11
+ patchright_cli/snapshot.py,sha256=_v9rU6U2k06ZmMFxKc7waKEK0juL3boeS4KVBo3drCQ,2400
12
12
  patchright_cli/snapshot_text.py,sha256=Fz_dszNn2U0rxX1Oc9e_U8PmarfmryK3b0Gm_1x3XUA,18553
13
13
  patchright_cli/toon.py,sha256=_1s1LByB1nKbSEus6lBLl8L1ASOmnRQNrbltikELGF0,10520
14
14
  patchright_cli/values.py,sha256=9pWnVfgYc4gZ7vcQJ5qwZvjvL-xdjNuBwvBAN5eyBjA,12110
@@ -20,8 +20,8 @@ patchright_cli/_skills/patchright-cli/references/snapshot-refs.md,sha256=sQC5-jZ
20
20
  patchright_cli/_skills/patchright-cli/references/storage-state.md,sha256=NjWZUxrD2iFJwZe5Byxzsf_gQXMZ95EarTgLbC9AaNI,3486
21
21
  patchright_cli/_skills/patchright-cli/references/tracing.md,sha256=6IwbGDsrtuB3bckB_7noyjpU37mdemIC435IuLilBZk,1931
22
22
  patchright_cli/_skills/patchright-cli/references/video-recording.md,sha256=qbWjMmE9-Q6Mp8deG2Cf6QY2fL4GOkx1_-erSGkoN8I,3905
23
- patchright_cli-0.11.0.dist-info/METADATA,sha256=8kERfzsbIipexi8iwzduiL_s0yrh1U0eMnDzmwPUzzg,38032
24
- patchright_cli-0.11.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
25
- patchright_cli-0.11.0.dist-info/entry_points.txt,sha256=B7btEnyGyZGe2cLL6pAfYAXJAT-prvy1Zbhduq935l0,62
26
- patchright_cli-0.11.0.dist-info/licenses/LICENSE,sha256=adszqL4YR4pQUrc7LeAFxeg5Mc2cvljXp7QPlzKVWm8,10760
27
- patchright_cli-0.11.0.dist-info/RECORD,,
23
+ patchright_cli-0.11.1.dist-info/METADATA,sha256=MgRNbei5JiF_yHdyb2fWoAF9H8KpWCDuXvTMFAWXbCI,38032
24
+ patchright_cli-0.11.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
25
+ patchright_cli-0.11.1.dist-info/entry_points.txt,sha256=B7btEnyGyZGe2cLL6pAfYAXJAT-prvy1Zbhduq935l0,62
26
+ patchright_cli-0.11.1.dist-info/licenses/LICENSE,sha256=adszqL4YR4pQUrc7LeAFxeg5Mc2cvljXp7QPlzKVWm8,10760
27
+ patchright_cli-0.11.1.dist-info/RECORD,,