
Nodriver Browser
- 5 installs
- 18 repo stars
- Updated May 17, 2026
- appautomaton/webmaton
nodriver-browser is a Claude skill that gives an agent a persistent CDP-driven Chrome/Chromium browser to navigate, snapshot, click and type across turns.
About
nodriver-browser is a persistent Chrome or Chromium browser automation skill built on nodriver. It auto-starts a headless or headed Chrome daemon and keeps one tab alive across calls so an agent can navigate, snapshot the DOM, click, type and screenshot in multi-step flows. A developer uses it when a page needs JavaScript rendering, logged-in session continuity, or interaction that WebFetch and search cannot do. It is not meant for static pages, simple searches, JSON APIs or one-off scrapes.
- Persistent Chrome/Chromium daemon that stays alive between Claude's turns via one tab
- Built on nodriver (CDP-direct, no Selenium, no navigator.webdriver) to beat anti-bot systems
- Snapshot gives numbered element refs so click/type/press can resolve them
Nodriver Browser by the numbers
- 5 all-time installs (skills.sh)
- Ranked #1,723 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
nodriver-browser capabilities & compatibility
free; spawns a Chromium process using ~150-200 MB RAM
- Capabilities
- playwright cli · html to markdown
- Works with
- chrome · playwright
- Use cases
- web scraping · web search
- Runs
- Runs locally
- Pricing
- Free
What nodriver-browser says it does
A persistent Chrome/Chromium browser that **stays alive between Claude's turns**.
WebFetch is blocked by **anti-bot** systems (Cloudflare, DataDome, Imperva, hCaptcha)
npx skills add https://github.com/appautomaton/webmaton --skill nodriver-browserAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 18 |
| Last updated | May 17, 2026 |
| Repository | appautomaton/webmaton ↗ |
What it does
Drive a persistent headless or headed Chrome across turns for JS rendering, logged-in sessions and multi-step click/type flows.
Who is it for?
interactive multi-step browser flows needing JS rendering or logged-in session state
Skip if: static HTML, one-shot searches, JSON API endpoints or single quick scrapes
When should I use this skill?
a page needs JavaScript, login continuity, clicking or typing, DOM snapshots, or WebFetch is anti-bot blocked
What you get
A long-running browser tab an agent drives with look-think-act primitives and stable element refs.
- persistent browser session
- DOM snapshots with element refs
- screenshots
By the numbers
- Chromium process uses ~150-200 MB RAM
Files
nodriver-browser
A persistent Chrome/Chromium browser that stays alive between Claude's turns. Built on nodriver (CDP-direct, no Selenium, no navigator.webdriver). Every script attaches to the same long-running browser, performs one action, exits — the browser and its tab keep going.
Core invariant: ONE daemon, ONE persistent tab (`tabs[0]`). Every script reports tabs_open in its output. If you ever see tabs_open > 1, treat it as a real signal that something opened a stray tab — read the warning and act on it.
When to use this skill
- The page needs JavaScript to render (SPA, infinite scroll, lazy load)
- WebFetch is blocked by anti-bot systems (Cloudflare, DataDome, Imperva, hCaptcha)
- The task requires interaction: clicking buttons, filling forms, multi-step flows, dropdown selection
- You need session state across multiple actions (logged-in scraping, multi-page checkout, OAuth flows)
- You need visual proof of a page (screenshot) for debugging or reporting
When NOT to use this skill
- Static HTML that loads fully on first GET → use
WebFetch - One-shot search query → use
WebSearch(or grok-search skill if it exists) - A JSON API endpoint → use
curlvia Bash, you don't need a browser at all - A single quick scrape with no interactivity → consider a one-off Python script, not this skill
This skill spawns a Chromium process (~150-200 MB RAM) that stays alive until you explicitly stop it. Worth it for interactive flows; overkill for one URL.
Quick start
Scripts live in scripts/ next to this SKILL.md — resolve paths relative to the skill root.
Run them directly as executables; the #!/usr/bin/env -S uv run --script shebang invokes uv and reads the PEP 723 metadata for you. Do not run these scripts with python, python3, or python -m; that bypasses the shebang, dependency metadata, and Python version pin. If executable dispatch is unavailable, use uv run --script scripts/nav.py ....
# 1. Navigate (auto-starts daemon on first call — no manual start needed)
scripts/nav.py https://news.ycombinator.com
# Optional: visible browser window, using the user's Chrome profile
scripts/nav.py --headed --user-profile https://example.com
# 2. Snapshot the page — gives you text + numbered refs for every interactive element
scripts/snapshot.py
# 3. Click something by ref id from the snapshot
scripts/click.py r17
# 4. Read state any time — it's the same tab as before, even from a fresh process
scripts/state.py
# 5. When done with the session, stop the daemon (frees ~190 MB)
scripts/stop_daemon.pyScript reference
All runnable scripts use uv run --script with PEP 723 metadata and return JSON to stdout. Invoke them directly, not through python or python3. Every script (except daemon control) appends tabs_open: N and emits a warning field if N > 1.
Leading browser options work on start_daemon.py and every script that auto-starts/attaches:
| Option | Purpose |
|---|---|
--headless | Start a headless daemon. This is the default when no daemon is running. |
--headed | Start a visible Chrome/Chromium window. If a daemon is already running in headless mode, stop it first. |
--skill-profile | Use the isolated profile at ~/.cache/nodriver-skill/profile/. This is the default. |
--user-profile | Use the user's Chrome profile root. Useful for existing logged-in state; requires that regular Chrome is not already locking the same profile. |
--profile-directory NAME | Use a Chrome profile directory such as Default or Profile 1; implies --user-profile. |
--user-data-dir PATH | Override the Chrome user-data root; implies --user-profile. |
--no-sandbox | Disable Chrome's OS sandbox. Use only when Chrome cannot start in constrained environments such as PRoot/container/root setups. Do not use for normal system Chrome or the user's Chrome profile. |
Environment equivalents: NODRIVER_SKILL_MODE=headed|headless, NODRIVER_SKILL_PROFILE=skill|user, NODRIVER_CHROME_PROFILE_DIRECTORY="Profile 1", NODRIVER_CHROME_USER_DATA_DIR=/path/to/User Data, NODRIVER_CHROME_NO_SANDBOX=1.
Daemon control
| Script | Purpose | Output |
|---|---|---|
start_daemon.py | Idempotent start. No-op if already running. Supports leading browser options. | {ok, pid, port, mode, profile, no_sandbox, already_running} |
stop_daemon.py | Kill daemon, clean PID file + stale singleton locks. Fails safely if a live CDP browser exists but no safe PID can be resolved. | {ok, stopped} or {ok: false, error} |
status.py | Daemon health + tab list. | {alive, pid, process: {uptime_s, rss_kb}, tabs: [...]} |
Navigation & state
| Script | Args | Purpose |
|---|---|---|
nav.py | URL | Navigate the persistent tab. Returns the new URL/title/scroll. |
state.py | — | Cheap status read of the current tab. No DOM mutation. |
back.py | — | history.back() |
forward.py | — | history.forward() |
reload.py | — | location.reload() |
Interaction (the look-think-act primitives)
| Script | Args | Purpose |
|---|---|---|
snapshot.py | — | Full page text + numbered interactive refs. Writes refs to `/tmp/nodriver-skill/refs.json` so click/type/press can resolve them. |
click.py | REF | Click element by ref id from latest snapshot. |
type.py | REF TEXT | Clear field and type. Dispatches input+change so React/Vue notice. |
hover.py | REF | Move mouse to element center via CDP, triggering CSS :hover and mouseover/mouseenter events. |
press.py | KEY or REF KEY | Send keyboard event (Enter, Tab, Escape, ArrowDown, single chars, ...). |
select.py | REF VALUE or REF --index N | Select an option from a <select> dropdown by value, visible text, or index. Dispatches change. |
scroll.py | `up\ | down\ |
upload.py | REF FILE [FILE...] | Set files on an <input type="file"> by ref. Validates element type, uses CDP DOM.setFileInputFiles, dispatches change. |
wait.py | SELECTOR [--text] [--timeout N] | Block until selector exists (or text appears with --text). Default 30s. |
eval.py | JS_EXPR | Escape hatch: arbitrary JS expression. Multi-statement → wrap in IIFE. |
Tab visibility & hygiene
| Script | Args | Purpose |
|---|---|---|
tabs.py | — | List ALL open tabs (index, url, title, target_id). Use this to see what's actually open. |
close_tab.py | INDEX | Close one tab by 0-indexed position. Refuses to close index 0. |
cleanup.py | — | Close every tab except tabs[0]. The "reset stray tabs" button. |
Misc
| Script | Args | Purpose |
|---|---|---|
screenshot.py | [PATH] [--full] | PNG of viewport (or full scrollable page with --full). Default path /tmp/nodriver-skill/last.png. |
The snapshot/refs model
snapshot.py is the single most important script. It does three things:
1. Walks the DOM for every interactive element (a[href], button, input, select, textarea, [role=button], [contenteditable], [onclick], ...) 2. Assigns each a stable ref id r1, r2, ... and mutates the DOM by setting data-nd-ref="rN" on each. This gives a stable CSS selector ([data-nd-ref="r17"]) that survives subsequent queries. 3. Writes the {ref: selector} map to /tmp/nodriver-skill/refs.json so click.py / type.py / press.py can look refs up.
Example output:
{
"url": "https://example.com/login",
"title": "Sign in",
"text": "Sign in to your account...",
"refs": [
{ "ref": "r1", "tag": "input", "type": "email", "name": "Email address", "visible": true, "bbox": [120, 200, 400, 40] },
{ "ref": "r2", "tag": "input", "type": "password", "name": "Password", ... },
{ "ref": "r3", "tag": "button", "type": "submit", "name": "Sign in", ... }
],
"tabs_open": 1
}To act on it:
scripts/type.py r1 "user@example.com"
scripts/type.py r2 "hunter2"
scripts/click.py r3Refs go stale on navigation or significant SPA re-render. If click.py returns "ref no longer in DOM", just re-run snapshot.py and try again.
Daemon lifecycle
The daemon is singleton-enforced via `fcntl.flock` on /tmp/nodriver-skill/start.lock. Five concurrent script invocations from a cold start will only ever spawn one Chromium.
- Auto-start: First call to any interaction script (nav, state, snapshot, ...) auto-starts the daemon if it's not running. You don't need to call
start_daemon.pyfirst unless you want to verify it manually or choose options like--headed --user-profile. - Persists: The daemon runs with
start_new_session=Trueso it survives the parent script exit. It will stay alive across all your turn boundaries until explicit shutdown. - Explicit stop:
stop_daemon.pyresolves the daemon PID from the pid file or the CDP debug port, SIGTERMs it, then SIGKILLs after 2s if needed, cleans the PID file and stale singleton locks. Run this at the end of any session that started the daemon. - Port: 9222 by default. Override with
NODRIVER_SKILL_PORT=9223if something else holds 9222. - Mode: headless by default. Use
--headedfor a visible window. You cannot change a running daemon from headless to headed; stop it first. - Profile: isolated skill profile by default:
~/.cache/nodriver-skill/profile/(cookies, localStorage, IndexedDB, etc.). Use--user-profilefor the user's Chrome profile. - Chrome binary: search order is
CHROMIUM_PATH/CHROME_PATHenv vars, thenPATHbinaries, then standard OS install paths, then the Playwright Chromium cache. SetCHROMIUM_PATH=/path/to/chrometo override. - Sandbox: Chrome's sandbox is enabled by default. Only pass
--no-sandboxfor constrained environments where Chrome cannot start with the OS sandbox, such as PRoot/container/root setups.
Tab hygiene (READ THIS)
In default headless mode there is no visible window. In headed mode you can see the browser, but the tab contract is still enforced by script output. Some sites open new tabs you didn't ask for: target="_blank" links, window.open() calls, popup ads, OAuth redirects.
The contract is one tab. If tabs_open > 1 in any script's output (and the warning field is set):
scripts/tabs.py # see what's actually open
scripts/cleanup.py # close everything except tabs[0]
# OR for surgical removal:
scripts/close_tab.py 2 # close just the tab at index 2Don't ignore the warning. Tabs accumulate. 30 stale tabs = ~2 GB of RAM and a confused state machine.
Footguns
- Refs go stale on re-render. SPAs that re-mount components on route change will lose
data-nd-refattributes. Re-runsnapshot.pyafter every navigation or significant action. - Concurrent navigations on the same tab race. Multiple processes can attach simultaneously, but two
nav.pycalls to different URLs at the same time will fight. Serialize them. - Daemon outlives the session. If you forget
stop_daemon.py, Chromium keeps running — silently eating ~190 MB of RAM until you explicitly stop it or reboot. Stop it when you're done. - User Chrome profile can be locked.
--user-profileuses the real Chrome profile root, so close normal Chrome first if startup fails or if Chrome attaches to the existing app instead of opening the CDP daemon. - `--no-sandbox` is not normal. It weakens browser isolation and shows Chrome's unsupported-flag banner in headed mode. Use it only for PRoot/container/root environments where normal sandboxed Chrome cannot start.
- `wait.py` polls every 250ms. Don't use it for sub-second timing-sensitive stuff.
- `type.py` clears the field first. If you need to append, read the existing value with
eval.pyfirst. - `eval.py` takes ONE expression, not statements. Multi-statement code:
eval.py '(() => { let x = 1; x++; return x; })()'. - Do not delete the profile without explicit user approval. The isolated profile at
~/.cache/nodriver-skill/profile/stores cookies, login sessions, and other persistent data. Never clear, reset, or remove it unless the user explicitly asks.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
daemon won't start | Chrome binary missing | Set CHROMIUM_PATH=/path/to/chrome or apt install chromium |
port 9222 in use by a non-CDP process | Another tool holds 9222 | NODRIVER_SKILL_PORT=9223 ./scripts/start_daemon.py |
PID is a chromium process but isn't responding | Crashed daemon left a zombie | ./scripts/stop_daemon.py to clean up, then retry |
daemon is already running in headless/headed mode | A running daemon cannot change visibility mode | ./scripts/stop_daemon.py, then restart with --headed or --headless |
daemon is already running with ... profile | A running daemon cannot change profile root | ./scripts/stop_daemon.py, then restart with --user-profile or --skill-profile |
daemon is already running with Chrome sandbox ... | A running daemon cannot change sandbox flags | ./scripts/stop_daemon.py, then restart with or without --no-sandbox |
Chrome says unsupported command-line flag: --no-sandbox | You started headed Chrome with sandbox disabled | Stop the daemon and restart without --no-sandbox unless you are in PRoot/container/root |
Chrome user data dir does not exist | The detected user profile root is missing | Use --user-data-dir PATH or fall back to --skill-profile |
no snapshot yet — run snapshot.py first | click.py called without prior snapshot | Run snapshot.py first |
ref no longer in DOM | Page navigated/re-rendered | Re-run snapshot.py, get the new ref |
tabs_open: 5, warning: ... | Site opened popups/new tabs | cleanup.py closes everything except tabs[0] |
Installed N packages log noise on first run | uv resolving deps for the inline script | Normal — only happens once per skill version |
| Hardlink errors during install | PRoot/container without hardlink support | Already mitigated: UV_LINK_MODE=copy is set automatically |
Chrome CDP daemon is alive ... no safe PID could be resolved | Stale/missing PID file and PID discovery failed | Use lsof -nP -iTCP:9222 -sTCP:LISTEN, inspect the process, then stop only that Chrome process |
Verifying it works
scripts/stop_daemon.py # clean state
scripts/nav.py https://example.com # cold-start auto-spawns daemon
scripts/state.py # SAME tab, fresh process
scripts/snapshot.py | head -20 # see refs
scripts/eval.py "document.title" # escape hatch
scripts/screenshot.py /tmp/test.png && ls -la /tmp/test.png
scripts/status.py # uptime + tab list
scripts/stop_daemon.py # doneIf the second call (state.py) reports the same URL as nav.py set, the persistent-tab invariant is working — every other script can rely on it.
"""
runner.py — shared library for the nodriver-browser skill.
Provides:
• find_chrome() — discovery + cache
• is_daemon_alive() — port-based liveness check
• pop_launch_mode() — parse leading browser launch flags
• ensure_daemon() — atomic singleton start/adopt (fcntl.flock)
• stop_daemon() — kill + clean
• attach() — async, returns nodriver Browser attached to the daemon
• get_persistent_tab(), list_tabs(), tab_count(), cleanup_extra_tabs()
• js(), output() — small async helpers used by every script
Design notes:
• nodriver is imported lazily inside async helpers, so daemon-control
scripts (start/stop/status) don't pay the import cost.
• ALL paths and constants live here. Scripts must not hardcode them.
• Port can be overridden with NODRIVER_SKILL_PORT for users who already
have something on 9222.
• Browser mode defaults to headless. Use a leading --headed script flag or
NODRIVER_SKILL_MODE=headed when spawning a visible browser.
• Profile defaults to the isolated skill profile. Use --user-profile or
NODRIVER_SKILL_PROFILE=user to launch against the user's Chrome profile.
• Chrome sandbox stays enabled by default. Use --no-sandbox or
NODRIVER_CHROME_NO_SANDBOX=1 only in constrained environments that cannot
run Chrome's OS sandbox, such as PRoot/container/root setups.
"""
from __future__ import annotations
import errno
import fcntl
import glob
import json
import os
import platform
import shutil
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
# ───────────────────────────────────────────────────────────── constants ──
PORT = int(os.environ.get("NODRIVER_SKILL_PORT", "9222"))
STATE_DIR = Path("/tmp/nodriver-skill")
PID_FILE = STATE_DIR / "pid"
LOCK_FILE = STATE_DIR / "start.lock"
LOG_FILE = STATE_DIR / "daemon.log"
MODE_FILE = STATE_DIR / "mode"
PROFILE_FILE = STATE_DIR / "profile.json"
SANDBOX_FILE = STATE_DIR / "sandbox"
REFS_FILE = STATE_DIR / "refs.json"
PERSISTENT_TAB_FILE = STATE_DIR / "persistent_tab_id"
CACHE_DIR = Path.home() / ".cache" / "nodriver-skill"
PROFILE_DIR = CACHE_DIR / "profile"
DAEMON_BOOT_TIMEOUT_S = 6.0
DAEMON_POLL_INTERVAL_S = 0.1
ALIVE_HTTP_TIMEOUT_S = 0.5
# Singleton-lock files Chromium leaves in the profile dir; safe to remove
# when we know there's no live process holding them.
STALE_LOCKS = ("SingletonLock", "SingletonCookie", "SingletonSocket")
VALID_LAUNCH_MODES = {"headless", "headed"}
VALID_PROFILE_MODES = {"skill", "user"}
def _ensure_dirs() -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
CACHE_DIR.mkdir(parents=True, exist_ok=True)
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
def _normalize_launch_mode(mode: str) -> str:
value = mode.strip().lower()
aliases = {
"headful": "headed",
"visible": "headed",
"gui": "headed",
}
value = aliases.get(value, value)
if value not in VALID_LAUNCH_MODES:
raise ValueError(
"launch mode must be 'headless' or 'headed' "
"(or use --headless / --headed)"
)
return value
def default_launch_mode() -> str:
"""Default mode used only when spawning a fresh daemon."""
return _normalize_launch_mode(os.environ.get("NODRIVER_SKILL_MODE", "headless"))
def pop_launch_mode(args: list[str]) -> tuple[str | None, list[str]]:
"""
Consume leading global browser launch flags from a script argv tail.
Returns (requested_mode, remaining_args). requested_mode is None when the
caller did not explicitly ask for a mode; in that case an existing daemon is
accepted as-is, and a new daemon uses default_launch_mode().
Profile flags are applied to this process through environment variables so
existing script call sites only need to pass the requested mode to attach().
"""
rest = list(args)
mode: str | None = None
profile: str | None = None
while rest:
flag = rest[0]
if flag in ("--headed", "--headless"):
rest.pop(0)
requested = "headed" if flag == "--headed" else "headless"
if mode is not None and mode != requested:
raise ValueError("choose only one of --headed or --headless")
mode = requested
continue
if flag in ("--user-profile", "--skill-profile"):
rest.pop(0)
requested_profile = "user" if flag == "--user-profile" else "skill"
if profile is not None and profile != requested_profile:
raise ValueError("choose only one of --user-profile or --skill-profile")
profile = requested_profile
os.environ["NODRIVER_SKILL_PROFILE"] = requested_profile
os.environ["NODRIVER_SKILL_PROFILE_EXPLICIT"] = "1"
continue
if flag == "--profile-directory":
rest.pop(0)
if not rest:
raise ValueError("--profile-directory requires a Chrome profile name")
if profile == "skill":
raise ValueError("--profile-directory requires --user-profile")
profile = "user"
os.environ["NODRIVER_CHROME_PROFILE_DIRECTORY"] = rest.pop(0)
os.environ["NODRIVER_SKILL_PROFILE"] = "user"
os.environ["NODRIVER_SKILL_PROFILE_EXPLICIT"] = "1"
continue
if flag == "--user-data-dir":
rest.pop(0)
if not rest:
raise ValueError("--user-data-dir requires a path")
if profile == "skill":
raise ValueError("--user-data-dir requires --user-profile")
profile = "user"
os.environ["NODRIVER_CHROME_USER_DATA_DIR"] = rest.pop(0)
os.environ["NODRIVER_SKILL_PROFILE"] = "user"
os.environ["NODRIVER_SKILL_PROFILE_EXPLICIT"] = "1"
continue
if flag == "--no-sandbox":
rest.pop(0)
os.environ["NODRIVER_CHROME_NO_SANDBOX"] = "1"
os.environ["NODRIVER_CHROME_NO_SANDBOX_EXPLICIT"] = "1"
continue
break
return mode, rest
def _normalize_profile_mode(mode: str) -> str:
value = mode.strip().lower()
aliases = {
"isolated": "skill",
"chrome": "user",
"default": "user",
"user-profile": "user",
}
value = aliases.get(value, value)
if value not in VALID_PROFILE_MODES:
raise ValueError("profile mode must be 'skill' or 'user'")
return value
def _chrome_user_data_dir() -> Path:
env_dir = os.environ.get("NODRIVER_CHROME_USER_DATA_DIR")
if env_dir:
return Path(env_dir).expanduser()
system = platform.system()
if system == "Darwin":
cands = [
Path.home() / "Library/Application Support/Google/Chrome",
Path.home() / "Library/Application Support/Chromium",
]
elif system == "Windows":
local_app_data = os.environ.get("LOCALAPPDATA")
base = Path(local_app_data) if local_app_data else Path.home() / "AppData/Local"
cands = [
base / "Google/Chrome/User Data",
base / "Chromium/User Data",
]
else:
cands = [
Path.home() / ".config/google-chrome",
Path.home() / ".config/chromium",
]
for c in cands:
if c.exists():
return c
return cands[0]
def _profile_mode_explicit() -> bool:
return (
"NODRIVER_SKILL_PROFILE" in os.environ
or "NODRIVER_SKILL_PROFILE_EXPLICIT" in os.environ
or "NODRIVER_CHROME_USER_DATA_DIR" in os.environ
or "NODRIVER_CHROME_PROFILE_DIRECTORY" in os.environ
)
def launch_profile() -> dict:
default_profile = "user" if (
"NODRIVER_CHROME_USER_DATA_DIR" in os.environ
or "NODRIVER_CHROME_PROFILE_DIRECTORY" in os.environ
) else "skill"
mode = _normalize_profile_mode(os.environ.get("NODRIVER_SKILL_PROFILE", default_profile))
if mode == "skill":
return {
"mode": "skill",
"user_data_dir": str(PROFILE_DIR),
"profile_directory": None,
}
user_data_dir = _chrome_user_data_dir()
profile_directory = os.environ.get("NODRIVER_CHROME_PROFILE_DIRECTORY", "Default")
return {
"mode": "user",
"user_data_dir": str(user_data_dir),
"profile_directory": profile_directory,
}
def _env_bool(name: str) -> bool:
value = os.environ.get(name, "")
return value.strip().lower() in {"1", "true", "yes", "on"}
def launch_no_sandbox() -> bool:
return _env_bool("NODRIVER_CHROME_NO_SANDBOX")
def _no_sandbox_explicit() -> bool:
return (
"NODRIVER_CHROME_NO_SANDBOX" in os.environ
or "NODRIVER_CHROME_NO_SANDBOX_EXPLICIT" in os.environ
)
# ─────────────────────────────────────────────────────── chrome discovery ──
def _candidate_paths() -> list[Path]:
"""Build the ordered candidate list — first match wins."""
cands: list[Path] = []
# 1. Explicit env var
env_path = os.environ.get("CHROMIUM_PATH") or os.environ.get("CHROME_PATH")
if env_path:
cands.append(Path(env_path))
# 2. PATH — prefer Chrome over Chromium
for name in ("google-chrome", "google-chrome-stable", "chrome",
"chromium", "chromium-browser"):
p = shutil.which(name)
if p:
cands.append(Path(p))
# 3. Standard system paths per OS
system = platform.system()
if system == "Linux":
cands += [
Path("/usr/bin/google-chrome"),
Path("/usr/bin/google-chrome-stable"),
Path("/opt/google/chrome/chrome"),
Path("/usr/bin/chromium"),
Path("/usr/bin/chromium-browser"),
Path("/snap/bin/chromium"),
]
elif system == "Darwin":
cands += [
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
]
elif system == "Windows":
cands += [
Path(r"C:/Program Files/Google/Chrome/Application/chrome.exe"),
Path(r"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"),
]
# 4. Playwright cache — pick newest by version number
pw_pattern = str(Path.home() / ".cache" / "ms-playwright" /
"chromium-*" / "chrome-linux" / "chrome")
pw_matches = sorted(
glob.glob(pw_pattern),
key=lambda p: int(Path(p).parts[-3].split("-")[1])
if Path(p).parts[-3].split("-")[1].isdigit() else 0,
reverse=True,
)
cands += [Path(p) for p in pw_matches]
return cands
def find_chrome() -> str:
"""
Locate a usable chromium-family binary.
Search order:
1. CHROMIUM_PATH / CHROME_PATH environment variables
2. PATH binaries (Chrome first, then Chromium)
3. Standard OS install paths
4. Playwright Chromium cache
Raises FileNotFoundError with install instructions if nothing found.
"""
for c in _candidate_paths():
try:
if c.exists() and os.access(c, os.X_OK):
return str(c)
except OSError:
continue
raise FileNotFoundError(
"No chromium binary found. Install one of:\n"
" • apt install chromium (Debian/Ubuntu)\n"
" • brew install --cask chromium (macOS)\n"
" • npx playwright install chromium\n"
"Or set CHROMIUM_PATH=/path/to/chrome"
)
# ──────────────────────────────────────────────────────── daemon liveness ──
def is_daemon_alive(port: int = PORT) -> bool:
"""
Authoritative liveness check: GET /json/version on the debug port.
Returns True only on a 200 with a valid Chrome `Browser` field.
"""
url = f"http://127.0.0.1:{port}/json/version"
try:
with urllib.request.urlopen(url, timeout=ALIVE_HTTP_TIMEOUT_S) as resp:
if resp.status != 200:
return False
data = json.loads(resp.read())
return isinstance(data.get("Browser"), str)
except (urllib.error.URLError, socket.timeout, ConnectionError,
json.JSONDecodeError, OSError):
return False
def _port_bound(port: int = PORT) -> bool:
"""True if SOMETHING accepts TCP on the port (CDP or otherwise)."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.2)
try:
s.connect(("127.0.0.1", port))
return True
except (ConnectionRefusedError, socket.timeout, OSError):
return False
finally:
s.close()
def _read_pid() -> int | None:
if not PID_FILE.exists():
return None
try:
pid = int(PID_FILE.read_text().strip())
except (ValueError, OSError):
return None
return pid if pid > 0 else None
def _process_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
def _process_cmdline(pid: int) -> str | None:
"""Best-effort process command line, using /proc first and ps as fallback."""
if pid <= 0:
return None
try:
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
return raw.replace(b"\0", b" ").decode("utf-8", "ignore").strip()
except (FileNotFoundError, PermissionError, OSError):
pass
try:
proc = subprocess.run(
["ps", "-p", str(pid), "-o", "command="],
capture_output=True,
text=True,
timeout=0.5,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
return proc.stdout.strip() or None
def _cmdline_is_chrome(cmdline: str) -> bool:
lower = cmdline.lower()
return "chrome" in lower or "chromium" in lower
def _cmdline_is_chrome_debug_daemon(cmdline: str, port: int = PORT) -> bool:
return _cmdline_is_chrome(cmdline) and f"--remote-debugging-port={port}" in cmdline
def _process_is_chrome(pid: int) -> bool:
"""Best-effort check that PID's cmdline points at a chromium binary."""
cmdline = _process_cmdline(pid)
return bool(cmdline and _cmdline_is_chrome(cmdline))
def _process_owns_debug_port(pid: int | None, port: int = PORT) -> bool:
if pid is None or not _process_alive(pid):
return False
cmdline = _process_cmdline(pid)
return bool(cmdline and _cmdline_is_chrome_debug_daemon(cmdline, port))
def _process_launch_mode(pid: int | None) -> str | None:
"""Best-effort mode detection for live daemons we did not start."""
if pid is None:
return None
cmdline = _process_cmdline(pid)
if not cmdline:
return None
if "--headless" in cmdline:
return "headless"
if _cmdline_is_chrome(cmdline):
return "headed"
return None
def running_launch_mode() -> str | None:
try:
mode = _normalize_launch_mode(MODE_FILE.read_text().strip())
return mode
except (FileNotFoundError, OSError, ValueError):
pass
return _process_launch_mode(_read_pid())
def running_profile() -> dict | None:
try:
data = json.loads(PROFILE_FILE.read_text())
user_data_dir = data.get("user_data_dir")
if not isinstance(user_data_dir, str) or not user_data_dir:
return None
mode = _normalize_profile_mode(data.get("mode", "skill"))
profile_directory = data.get("profile_directory")
if profile_directory is not None and not isinstance(profile_directory, str):
profile_directory = None
return {
"mode": mode,
"user_data_dir": user_data_dir,
"profile_directory": profile_directory,
}
except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError):
return None
def running_no_sandbox() -> bool | None:
try:
value = SANDBOX_FILE.read_text().strip()
if value == "disabled":
return True
if value == "enabled":
return False
except (FileNotFoundError, OSError):
pass
pid = _read_pid()
if pid is None:
return None
cmdline = _process_cmdline(pid)
if not cmdline:
return None
if "--no-sandbox" in cmdline:
return True
if _cmdline_is_chrome(cmdline):
return False
return None
def _atomic_write_pid(pid: int) -> None:
if pid <= 0:
raise ValueError("refusing to write invalid daemon PID")
tmp = PID_FILE.with_suffix(".tmp")
tmp.write_text(str(pid))
tmp.replace(PID_FILE)
def _atomic_write_mode(mode: str) -> None:
tmp = MODE_FILE.with_suffix(".tmp")
tmp.write_text(mode)
tmp.replace(MODE_FILE)
def _atomic_write_profile(profile: dict) -> None:
tmp = PROFILE_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(profile, indent=2, sort_keys=True))
tmp.replace(PROFILE_FILE)
def _atomic_write_sandbox(disabled: bool) -> None:
tmp = SANDBOX_FILE.with_suffix(".tmp")
tmp.write_text("disabled" if disabled else "enabled")
tmp.replace(SANDBOX_FILE)
def _same_profile(left: dict | None, right: dict | None) -> bool:
if left is None or right is None:
return False
return (
Path(left["user_data_dir"]).expanduser()
== Path(right["user_data_dir"]).expanduser()
and left.get("profile_directory") == right.get("profile_directory")
)
def _clean_stale_locks() -> None:
for name in STALE_LOCKS:
p = PROFILE_DIR / name
try:
if p.is_symlink() or p.exists():
p.unlink()
except OSError:
pass
# ─────────────────────────────────────────────── singleton daemon control ──
class _StartLock:
"""Context manager wrapping fcntl.flock(LOCK_EX) on LOCK_FILE."""
def __enter__(self):
_ensure_dirs()
self._fd = open(LOCK_FILE, "w")
fcntl.flock(self._fd.fileno(), fcntl.LOCK_EX)
return self
def __exit__(self, *exc):
try:
fcntl.flock(self._fd.fileno(), fcntl.LOCK_UN)
finally:
self._fd.close()
def ensure_daemon(mode: str | None = None) -> int | None:
"""
Idempotent + race-free: guarantee exactly one Chromium daemon is running
on PORT, and return its PID when safely known. Safe to call from
concurrent processes.
If mode is explicit and a daemon already exists in the opposite mode, the
caller must stop it first; a running browser cannot be made headed/headless
in place.
"""
_ensure_dirs()
requested_mode = _normalize_launch_mode(mode) if mode is not None else None
requested_profile = launch_profile()
profile_explicit = _profile_mode_explicit()
requested_no_sandbox = launch_no_sandbox()
no_sandbox_explicit = _no_sandbox_explicit()
with _StartLock():
# Re-check inside the lock — someone else may have just started it.
if is_daemon_alive():
pid = _read_pid()
if not _process_owns_debug_port(pid):
# Adopt: alive but no usable PID file (e.g. survived state wipe).
pid = _find_chrome_pid_on_port()
if pid is not None:
_atomic_write_pid(pid)
current_mode = running_launch_mode()
if requested_mode is not None:
if current_mode is None:
raise RuntimeError(
f"daemon is already running on port {PORT}, but its mode is unknown; "
f"run stop_daemon.py before starting {requested_mode} mode"
)
if requested_mode != current_mode:
raise RuntimeError(
f"daemon is already running in {current_mode} mode on port {PORT}; "
f"run stop_daemon.py before starting {requested_mode} mode"
)
current_profile = running_profile()
if profile_explicit:
if current_profile is None:
raise RuntimeError(
"daemon is already running, but its profile is unknown; "
"run stop_daemon.py before changing profiles"
)
if not _same_profile(requested_profile, current_profile):
raise RuntimeError(
"daemon is already running with "
f"{current_profile['mode']} profile at {current_profile['user_data_dir']}; "
"run stop_daemon.py before changing profiles"
)
current_no_sandbox = running_no_sandbox()
if no_sandbox_explicit:
if current_no_sandbox is None:
raise RuntimeError(
"daemon is already running, but its sandbox setting is unknown; "
"run stop_daemon.py before changing sandbox flags"
)
if requested_no_sandbox != current_no_sandbox:
current = "disabled" if current_no_sandbox else "enabled"
requested = "disabled" if requested_no_sandbox else "enabled"
raise RuntimeError(
f"daemon is already running with Chrome sandbox {current}; "
f"run stop_daemon.py before starting with sandbox {requested}"
)
return pid
# Port bound but not CDP → alien process. Refuse.
if _port_bound():
raise RuntimeError(
f"port {PORT} is in use by a non-CDP process. "
f"Free it, or set NODRIVER_SKILL_PORT to a different port."
)
# Stale PID file? Either a dead process or an alien live one.
pid = _read_pid()
if pid is not None:
if _process_alive(pid):
if _process_is_chrome(pid):
raise RuntimeError(
f"PID {pid} is a chromium process but isn't responding "
f"on port {PORT}. Run stop_daemon.py to clean up."
)
raise RuntimeError(
f"stale PID file points at live non-chromium PID {pid}. "
f"Remove {PID_FILE} manually."
)
# dead process → safe to clean and restart
_clean_stale_locks()
try:
PID_FILE.unlink()
except FileNotFoundError:
pass
# Spawn fresh.
launch_mode = requested_mode or default_launch_mode()
launch_profile_spec = requested_profile
user_data_dir = Path(launch_profile_spec["user_data_dir"]).expanduser()
if launch_profile_spec["mode"] == "user" and not user_data_dir.exists():
raise FileNotFoundError(
f"Chrome user data dir does not exist: {user_data_dir}. "
"Use --user-data-dir PATH or --skill-profile."
)
chrome = find_chrome()
launch_args = [
chrome,
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-default-apps",
"--disable-extensions",
"--disable-sync",
"--mute-audio",
f"--remote-debugging-port={PORT}",
"--remote-debugging-address=127.0.0.1",
f"--user-data-dir={user_data_dir}",
]
if launch_mode == "headless":
launch_args.insert(1, "--headless=new")
if requested_no_sandbox:
launch_args.insert(1, "--no-sandbox")
if launch_profile_spec.get("profile_directory"):
launch_args.append(f"--profile-directory={launch_profile_spec['profile_directory']}")
log_fp = open(LOG_FILE, "ab")
proc = subprocess.Popen(
launch_args,
stdout=log_fp,
stderr=log_fp,
stdin=subprocess.DEVNULL,
start_new_session=True, # detach from our process group
close_fds=True,
)
_atomic_write_pid(proc.pid)
_atomic_write_mode(launch_mode)
_atomic_write_sandbox(requested_no_sandbox)
_atomic_write_profile({
**launch_profile_spec,
"user_data_dir": str(user_data_dir),
})
# Poll for readiness.
deadline = time.monotonic() + DAEMON_BOOT_TIMEOUT_S
while time.monotonic() < deadline:
if is_daemon_alive():
return proc.pid
if proc.poll() is not None:
raise RuntimeError(
f"chromium exited prematurely (rc={proc.returncode}). "
f"See {LOG_FILE} for details."
)
time.sleep(DAEMON_POLL_INTERVAL_S)
# Timeout — kill and complain.
try:
proc.kill()
except Exception:
pass
try:
PID_FILE.unlink()
except FileNotFoundError:
pass
try:
MODE_FILE.unlink()
except FileNotFoundError:
pass
try:
PROFILE_FILE.unlink()
except FileNotFoundError:
pass
try:
SANDBOX_FILE.unlink()
except FileNotFoundError:
pass
raise RuntimeError(
f"chromium failed to come up within {DAEMON_BOOT_TIMEOUT_S}s. "
f"See {LOG_FILE} for details."
)
def _find_chrome_pid_on_port_proc(port: int = PORT) -> int | None:
"""Best-effort Linux/ProcFS lookup for a chrome PID on our debug port."""
try:
for d in Path("/proc").iterdir():
if not d.name.isdigit():
continue
try:
raw = (d / "cmdline").read_bytes()
except (FileNotFoundError, PermissionError, OSError):
continue
cmdline = raw.replace(b"\0", b" ").decode("utf-8", "ignore")
if _cmdline_is_chrome_debug_daemon(cmdline, port):
return int(d.name)
except OSError:
pass
return None
def _find_chrome_pid_on_port_lsof(port: int = PORT) -> int | None:
"""Best-effort macOS/Unix lookup for a chrome PID listening on the port."""
if not shutil.which("lsof"):
return None
try:
proc = subprocess.run(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
capture_output=True,
text=True,
timeout=1.0,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
for line in proc.stdout.splitlines():
line = line.strip()
if not line.isdigit():
continue
pid = int(line)
if _process_owns_debug_port(pid, port):
return pid
return None
def _find_chrome_pid_on_port(port: int = PORT) -> int | None:
"""Best-effort: find a chrome PID with our --remote-debugging-port=PORT."""
return _find_chrome_pid_on_port_proc(port) or _find_chrome_pid_on_port_lsof(port)
def _resolve_daemon_pid(port: int = PORT) -> int | None:
pid = _read_pid()
if _process_owns_debug_port(pid, port):
return pid
return _find_chrome_pid_on_port(port)
def _clear_session_state() -> None:
"""Remove ephemeral state that's only valid while a daemon is running."""
for f in (PID_FILE, MODE_FILE, PROFILE_FILE, SANDBOX_FILE, PERSISTENT_TAB_FILE, REFS_FILE):
try:
f.unlink()
except FileNotFoundError:
pass
except OSError:
pass
def stop_daemon() -> bool:
"""
Kill the daemon (SIGTERM, then SIGKILL after 2s) and clean up state.
Returns True if a daemon was running, False if there was nothing to stop.
"""
with _StartLock():
alive = is_daemon_alive()
pid = _resolve_daemon_pid()
if pid is None:
_clear_session_state()
_clean_stale_locks()
if alive:
raise RuntimeError(
f"Chrome CDP daemon is alive on port {PORT}, but no safe PID "
"could be resolved. Refusing to kill an unidentified process."
)
return False
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
for _ in range(20):
if not _process_alive(pid):
break
time.sleep(0.1)
else:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
for _ in range(20):
if not is_daemon_alive():
break
time.sleep(0.1)
else:
raise RuntimeError(
f"sent shutdown signals to PID {pid}, but Chrome CDP is still "
f"alive on port {PORT}"
)
_clear_session_state()
_clean_stale_locks()
return True
# ──────────────────────────────────────────────────────────── nodriver ────
async def attach(mode: str | None = None):
"""
Attach to the running daemon (auto-starting it if necessary).
Returns a nodriver Browser instance.
"""
ensure_daemon(mode=mode)
profile = running_profile() or launch_profile()
import nodriver as uc # lazy
config = uc.Config(
host="127.0.0.1",
port=PORT,
browser_executable_path=find_chrome(), # validated by Config.__init__
)
# Tell nodriver this is OUR profile dir, not a temp scratch one — this
# sets _custom_data_dir=True so deconstruct_browser() skips its rmtree
# and the noisy "successfully removed temp profile" print at exit.
config.user_data_dir = profile["user_data_dir"]
browser = await uc.Browser.create(config=config)
await browser.start()
return browser
async def _refresh_targets(browser) -> None:
# nodriver renamed/added this method across versions; try both.
if hasattr(browser, "update_targets"):
await browser.update_targets()
elif hasattr(browser, "_update_targets"):
await browser._update_targets()
def _page_tabs(browser) -> list:
"""
Return all page-type tabs, deduplicated by target_id.
nodriver's `browser.tabs` can contain the same target twice (it adds
the existing target on attach AND on update_targets without dedup).
We trust the CDP target_id as the unique identity.
"""
seen: set[str] = set()
out: list = []
for t in browser.tabs:
if getattr(t, "type_", None) != "page":
continue
tid = getattr(t, "target_id", None)
if tid and tid in seen:
continue
if tid:
seen.add(tid)
out.append(t)
return out
async def get_persistent_tab(browser):
"""
Return THE persistent tab — identified by stable target_id, not by list
position. The id is stored in /tmp/nodriver-skill/persistent_tab_id on
first call and re-used forever. This is critical: when a stray tab
appears (window.open, target=_blank, ...), CDP may report the new tab
at index 0, which would silently swap our persistent tab if we trusted
list order.
Fallbacks (in order):
1. Saved target_id resolves to a live tab → use it
2. Saved id is gone → use the OLDEST page tab and re-pin to its id
3. No page tabs exist → open about:blank in-place and pin to it
"""
await _refresh_targets(browser)
tabs = _page_tabs(browser)
# 1. Saved id if it still exists
if PERSISTENT_TAB_FILE.exists():
saved_id = PERSISTENT_TAB_FILE.read_text().strip()
for t in tabs:
if getattr(t, "target_id", None) == saved_id:
return t
# Saved id is stale — fall through to repin
# 2. Repin: pick the oldest existing tab. browser.tabs preserves
# discovery order, so the first one we ever saw is generally tabs[0]
# at fresh-daemon time. (After strays appear, this may not be index 0
# in CDP order, but as long as we pin once and resolve by id thereafter,
# we're stable.)
if tabs:
chosen = tabs[0]
target_id = getattr(chosen, "target_id", None)
if target_id:
STATE_DIR.mkdir(parents=True, exist_ok=True)
PERSISTENT_TAB_FILE.write_text(target_id)
return chosen
# 3. No tabs at all — open one and pin
await browser.get("about:blank", new_tab=False)
await _refresh_targets(browser)
tabs = _page_tabs(browser)
if not tabs:
raise RuntimeError("daemon has zero page tabs and could not create one")
chosen = tabs[0]
target_id = getattr(chosen, "target_id", None)
if target_id:
STATE_DIR.mkdir(parents=True, exist_ok=True)
PERSISTENT_TAB_FILE.write_text(target_id)
return chosen
def _persistent_target_id() -> str | None:
if PERSISTENT_TAB_FILE.exists():
v = PERSISTENT_TAB_FILE.read_text().strip()
return v or None
return None
async def list_tabs(browser) -> list[dict]:
"""List every page-type tab with metadata. is_persistent is by target_id."""
await _refresh_targets(browser)
tabs = _page_tabs(browser)
pinned = _persistent_target_id()
out = []
for i, t in enumerate(tabs):
title = None
try:
title = await js(t, "document.title")
except Exception:
pass
target_id = getattr(t, "target_id", None)
out.append({
"index": i,
"url": getattr(t, "url", None),
"title": title,
"target_id": target_id,
"is_persistent": (target_id == pinned),
})
return out
async def tab_count(browser) -> int:
"""Force a fresh CDP target list before counting."""
await _refresh_targets(browser)
return len(_page_tabs(browser))
async def cleanup_extra_tabs(browser) -> int:
"""
Close every page-type tab except the persistent one (pinned by target_id).
Returns number of tabs actually closed.
After closing, waits briefly for nodriver to process Target.targetDestroyed
events so the next tab_count call sees the post-cleanup state.
"""
import asyncio
await _refresh_targets(browser)
tabs = _page_tabs(browser)
pinned = _persistent_target_id()
closed = 0
for t in tabs:
if getattr(t, "target_id", None) == pinned:
continue
try:
await t.close()
closed += 1
except Exception:
pass
# Give nodriver a moment to receive the Target.targetDestroyed events,
# then refresh so callers see an accurate count.
if closed:
await asyncio.sleep(0.25)
await _refresh_targets(browser)
return closed
async def js(tab, expr: str):
"""
Run JS and return plain Python data, not nodriver's CDP RemoteObject
envelope. Wraps the expression in JSON.stringify so we get a string we
can json.loads.
Defensive: if the JS throws OR returns something JSON.stringify can't
handle (Window object from window.open, DOM nodes, etc.), nodriver
surfaces an ExceptionDetails object. We coerce that to a string so the
caller's output() never crashes on json.dumps.
"""
try:
raw = await tab.evaluate(f"JSON.stringify({expr})")
except Exception as e:
return {"_js_error": f"{type(e).__name__}: {e}"}
if raw is None:
return None
if isinstance(raw, str):
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw
if isinstance(raw, (int, float, bool, list, dict)):
return raw
# ExceptionDetails or some other CDP wrapper — best-effort string repr.
return {"_js_unserializable": str(raw)[:500]}
async def output(payload: dict, browser=None) -> None:
"""
Centralized print. ALWAYS appends `tabs_open` (when browser provided)
and a `warning` field if more than one tab is open. Every script must
use this — never bare `print(json.dumps(...))`.
"""
if browser is not None:
try:
n = await tab_count(browser)
payload["tabs_open"] = n
if n > 1:
payload["warning"] = (
f"{n} tabs open, expected 1. "
f"Run cleanup.py to close stray tabs."
)
except Exception as e:
payload["tabs_open_error"] = str(e)
print(json.dumps(payload, indent=2, ensure_ascii=False))
"""
snapshot.py — DOM walker that gives the model a "view" of the current page.
It does two things in one shot:
1. Find every interactive element (links, buttons, inputs, role-based
widgets, contenteditable, [onclick]) and assign each a sequential
ref id (`r1`, `r2`, ...).
2. MUTATE the DOM by writing `data-nd-ref="rN"` onto each element. This
gives us a stable CSS selector (`[data-nd-ref="r17"]`) that survives
re-querying within the same page lifetime.
The caller (scripts/snapshot.py) writes `{ref: selector}` to refs.json so
click.py / type.py / press.py can resolve a ref into a selector later.
Refs are page-scoped: a navigation or significant SPA re-render invalidates
them. The script is cheap to re-run.
"""
from __future__ import annotations
import json
# JS payload. Kept as a single expression so JSON.stringify can wrap the
# whole thing for the js() helper in runner.py.
SNAPSHOT_JS = r"""
(() => {
const SEL = [
'a[href]',
'button',
'input:not([type="hidden"])',
'select',
'textarea',
'[role="button"]',
'[role="link"]',
'[role="textbox"]',
'[role="combobox"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="menuitem"]',
'[role="tab"]',
'[contenteditable="true"]',
'[onclick]',
].join(', ');
const isVisible = (el) => {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') return false;
return true;
};
const cleanText = (s) => (s || '').replace(/\s+/g, ' ').trim().slice(0, 100);
const nameOf = (el) => {
return cleanText(
el.getAttribute('aria-label') ||
el.getAttribute('alt') ||
el.innerText ||
el.value ||
el.placeholder ||
el.getAttribute('title') ||
el.getAttribute('name') ||
''
);
};
// Walk and collect. Skip elements that already have a ref from a previous
// snapshot — keep the older id stable so click/type calls referencing the
// earlier snapshot still work as long as the element survived.
const all = Array.from(document.querySelectorAll(SEL));
let nextId = 1;
// Find the highest existing ref so we don't collide.
for (const el of all) {
const existing = el.dataset && el.dataset.ndRef;
if (existing && /^r(\d+)$/.test(existing)) {
const n = parseInt(existing.slice(1), 10);
if (n >= nextId) nextId = n + 1;
}
}
const refs = [];
for (const el of all) {
let refId = el.dataset.ndRef;
if (!refId) {
refId = 'r' + (nextId++);
el.dataset.ndRef = refId;
}
const r = el.getBoundingClientRect();
refs.push({
ref: refId,
tag: el.tagName.toLowerCase(),
type: el.type || null,
role: el.getAttribute('role') || null,
name: nameOf(el),
href: el.tagName === 'A' ? (el.href || null) : null,
value: ('value' in el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'))
? (el.value || null) : null,
visible: isVisible(el),
bbox: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
});
}
return {
url: location.href,
title: document.title,
text: (document.body && document.body.innerText || '').slice(0, 8000),
refs: refs,
};
})()
"""
async def take_snapshot(tab) -> dict:
"""
Run the snapshot JS in `tab`, return the parsed dict.
Returns: {url, title, text, refs: [{ref, tag, type, role, name, href, value, visible, bbox}]}
"""
raw = await tab.evaluate(f"JSON.stringify({SNAPSHOT_JS})")
if raw is None:
return {"url": None, "title": None, "text": "", "refs": []}
if isinstance(raw, str):
return json.loads(raw)
return raw
def selector_for(ref: str) -> str:
"""The CSS selector that resolves a ref id back to its element."""
return f'[data-nd-ref="{ref}"]'
def build_selector_map(snapshot: dict) -> dict[str, str]:
"""{r1: '[data-nd-ref="r1"]', ...} — what gets written to refs.json."""
return {r["ref"]: selector_for(r["ref"]) for r in snapshot.get("refs", [])}
Constrained Environments (PRoot / Container / Root)
Headed mode on systems without a native display server (common in PRoot, Docker, or root-only containers) requires a lightweight virtual display.
Quick setup: Xvfb
# Start a virtual display on :99. Use setsid so it survives session boundaries.
setsid Xvfb :99 -screen 0 1280x720x24 -ac >/dev/null 2>&1 &
# Export before every headed daemon launch.
export DISPLAY=:99
# Then use the skill normally.
scripts/start_daemon.py --headed --no-sandboxWhy setsid?
Each bash tool invocation is a fresh session. Background processes die when the session ends. setsid detaches Xvfb so it persists across turns.
Cleanup
pkill -f "Xvfb :99"See also
--no-sandboxusage is documented inSKILL.mdunder the daemon lifecycle and troubleshooting sections.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""Navigate back in the persistent tab's history."""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if args:
print(json.dumps({"error": "usage: back.py [--headed|--headless]"}, indent=2))
return 2
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
await js(tab, "(history.back(), true)")
await tab.wait(1)
state = await js(tab, "({url: location.href, title: document.title})")
await output({"action": "back", **state}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Close every tab except the persistent one (index 0). The "reset stray tabs"
button — run this when state.py shows tabs_open > 1 with a warning.
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, cleanup_extra_tabs, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if args:
print(json.dumps({"error": "usage: cleanup.py [--headed|--headless]"}, indent=2))
return 2
browser = await attach(mode=mode)
closed = await cleanup_extra_tabs(browser)
await output({"closed": closed}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Click an element by ref id from the latest snapshot. Usage: click.py r17
Refs come from snapshot.py. They expire when the page navigates or the SPA
re-renders the relevant subtree — re-snapshot if click fails with "not found".
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode, REFS_FILE # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 1:
print('{"error": "usage: click.py [--headed|--headless] REF"}')
return 2
ref = args[0]
if not REFS_FILE.exists():
print(json.dumps({
"error": "no snapshot yet — run snapshot.py first",
"refs_file": str(REFS_FILE),
}, indent=2))
return 1
refs = json.loads(REFS_FILE.read_text())
selector = refs.get(ref)
if not selector:
print(json.dumps({
"error": f"ref {ref!r} not in last snapshot",
"available_refs": list(refs.keys())[:20],
}, indent=2))
return 1
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
# Check the element still exists, then click it.
found = await js(tab, f"!!document.querySelector({json.dumps(selector)})")
if not found:
await output({
"error": f"ref {ref} no longer in DOM (page may have re-rendered)",
"selector": selector,
"hint": "re-run snapshot.py and try again",
}, browser=browser)
return 1
# Capture pre-click state for the report.
before = await js(tab, """
({ url: location.href, title: document.title })
""")
await js(tab, f"document.querySelector({json.dumps(selector)}).click()")
# Wait for any navigation/transition.
await tab.wait(1.5)
after = await js(tab, """
({ url: location.href, title: document.title })
""")
await output({
"ref": ref,
"selector": selector,
"before": before,
"after": after,
"navigated": before["url"] != after["url"],
}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Close a single tab by its 0-indexed position. Refuses to close index 0
(the persistent tab) — use stop_daemon.py for a full reset instead.
Usage: close_tab.py 2
"""
import asyncio
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import ( # noqa: E402
attach, _refresh_targets, _page_tabs, _persistent_target_id, output,
pop_launch_mode,
)
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 1:
print('{"error": "usage: close_tab.py [--headed|--headless] INDEX"}')
return 2
try:
index = int(args[0])
except ValueError:
print('{"error": "INDEX must be an integer"}')
return 2
browser = await attach(mode=mode)
await _refresh_targets(browser)
tabs = _page_tabs(browser)
if index < 0 or index >= len(tabs):
await output({
"error": f"index {index} out of range (have {len(tabs)} tabs)",
"valid_range": [0, len(tabs) - 1] if tabs else [],
}, browser=browser)
return 1
target = tabs[index]
target_id = getattr(target, "target_id", None)
pinned = _persistent_target_id()
if target_id == pinned:
await output({
"error": f"refusing to close the persistent tab (target_id={target_id}). "
f"Use stop_daemon.py to reset everything.",
"index": index,
}, browser=browser)
return 1
closed_url = getattr(target, "url", None)
try:
await target.close()
except Exception as e:
await output({"error": f"close failed: {e}"}, browser=browser)
return 1
# Wait for nodriver to receive Target.targetDestroyed before tab_count.
await asyncio.sleep(0.25)
await output({
"closed_index": index,
"closed_url": closed_url,
"closed_target_id": target_id,
}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Run arbitrary JS in the persistent tab. Escape hatch.
Usage: eval.py 'document.title'
eval.py 'document.querySelectorAll("a").length'
eval.py 'JSON.stringify(Object.keys(window))'
The expression should be a single JS expression, not statements. Wrap
multi-statement code in an IIFE: '(() => { ...; return value; })()'
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 1:
print('{"error": "usage: eval.py [--headed|--headless] JS_EXPRESSION"}')
return 2
expr = args[0]
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
try:
result = await js(tab, expr)
await output({"result": result}, browser=browser)
except Exception as e:
await output({"error": str(e), "type": type(e).__name__}, browser=browser)
return 1
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""Navigate forward in the persistent tab's history."""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if args:
print(json.dumps({"error": "usage: forward.py [--headed|--headless]"}, indent=2))
return 2
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
await js(tab, "(history.forward(), true)")
await tab.wait(1)
state = await js(tab, "({url: location.href, title: document.title})")
await output({"action": "forward", **state}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Hover over an element by ref. Usage: hover.py REF
Moves the mouse to the element's center via CDP Input.dispatchMouseEvent,
triggering CSS :hover and dispatching mouseover/mouseenter JS events.
Useful for revealing dropdown menus, tooltips, and hover-dependent UI.
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode, REFS_FILE # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 1:
print('{"error": "usage: hover.py [--headed|--headless] REF"}')
return 2
ref = args[0]
if not REFS_FILE.exists():
print(json.dumps({"error": "no snapshot yet — run snapshot.py first"}))
return 1
refs = json.loads(REFS_FILE.read_text())
selector = refs.get(ref)
if not selector:
print(json.dumps({"error": f"ref {ref!r} not in last snapshot"}))
return 1
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
# Get bounding box center.
bbox = await js(tab, f"""
(() => {{
const el = document.querySelector({json.dumps(selector)});
if (!el) return null;
const r = el.getBoundingClientRect();
return {{ x: r.x + r.width / 2, y: r.y + r.height / 2 }};
}})()
""")
if not bbox:
await output({"error": f"ref {ref} no longer in DOM", "hint": "re-run snapshot.py"}, browser=browser)
return 1
cx, cy = bbox["x"], bbox["y"]
# CDP mouse move — triggers CSS :hover.
import nodriver.cdp.input_ as cdp_input
await tab.send(cdp_input.dispatch_mouse_event(type_="mouseMoved", x=cx, y=cy))
# JS events for frameworks listening on mouseover/mouseenter.
await js(tab, f"""
(() => {{
const el = document.querySelector({json.dumps(selector)});
el.dispatchEvent(new MouseEvent('mouseover', {{bubbles: true, clientX: {cx}, clientY: {cy}}}));
el.dispatchEvent(new MouseEvent('mouseenter', {{bubbles: false, clientX: {cx}, clientY: {cy}}}));
}})()
""")
await tab.wait(0.3)
await output({"ref": ref, "selector": selector, "position": {"x": cx, "y": cy}}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""Navigate the persistent tab to a URL. Usage: nav.py [--headed|--headless] URL"""
import asyncio
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 1:
print('{"error": "usage: nav.py [--headed|--headless] URL"}')
return 2
url = args[0]
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
await tab.get(url)
# Give it a moment to start rendering before we read state.
await tab.wait(1)
state = await js(tab, """
({
url: location.href,
title: document.title,
ready_state: document.readyState,
scroll: window.scrollY,
text_len: (document.body && document.body.innerText || '').length
})
""")
await output({"navigated_to": url, **state}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Send a keyboard key event to the focused element (or the page).
Usage: press.py Enter
press.py Tab
press.py Escape
press.py ArrowDown
press.py r17 Enter # focus REF first, then press
Common keys: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right,
PageUp, PageDown, Home, End. Single characters also work: press.py "a".
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode, REFS_FILE # noqa: E402
KEY_JS = r"""
(sel, key) => {
let target = document.activeElement || document.body;
if (sel) {
const el = document.querySelector(sel);
if (!el) return { ok: false, error: 'ref not found' };
el.focus();
target = el;
}
// Map name → KeyboardEvent code
const codeMap = {
Enter: 'Enter', Tab: 'Tab', Escape: 'Escape', Backspace: 'Backspace',
Delete: 'Delete', ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight',
PageUp: 'PageUp', PageDown: 'PageDown', Home: 'Home', End: 'End',
Space: 'Space', ' ': 'Space',
};
const code = codeMap[key] || (key.length === 1 ? 'Key' + key.toUpperCase() : key);
const opts = { key, code, bubbles: true, cancelable: true };
target.dispatchEvent(new KeyboardEvent('keydown', opts));
target.dispatchEvent(new KeyboardEvent('keypress', opts));
target.dispatchEvent(new KeyboardEvent('keyup', opts));
return { ok: true, key, code, target_tag: target.tagName.toLowerCase() };
}
"""
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 1:
print('{"error": "usage: press.py [--headed|--headless] KEY OR press.py [--headed|--headless] REF KEY"}')
return 2
if len(args) == 1:
ref, key = None, args[0]
selector = None
else:
ref, key = args[0], args[1]
if not REFS_FILE.exists():
print(json.dumps({"error": "run snapshot.py first"}, indent=2))
return 1
refs = json.loads(REFS_FILE.read_text())
selector = refs.get(ref)
if not selector:
print(json.dumps({"error": f"unknown ref {ref!r}"}, indent=2))
return 1
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
expr = f"({KEY_JS})({json.dumps(selector)}, {json.dumps(key)})"
result = await js(tab, expr)
# Brief settle for any handler-driven navigation.
await tab.wait(0.5)
await output({"ref": ref, **(result or {})}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""Reload the persistent tab."""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if args:
print(json.dumps({"error": "usage: reload.py [--headed|--headless]"}, indent=2))
return 2
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
await js(tab, "(location.reload(), true)")
await tab.wait(1.5)
state = await js(tab, "({url: location.href, title: document.title, ready_state: document.readyState})")
await output({"action": "reload", **state}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Capture a PNG of the persistent tab.
Usage: screenshot.py — saves to /tmp/nodriver-skill/last.png
screenshot.py /path/to/out.png — custom path
screenshot.py --full /tmp/full.png — full scrollable page (not just viewport)
"""
import os
import json
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, output, pop_launch_mode, STATE_DIR # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
full_page = "--full" in args
args = [a for a in args if a != "--full"]
out_path = Path(args[0]) if args else (STATE_DIR / "last.png")
out_path.parent.mkdir(parents=True, exist_ok=True)
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
# nodriver's save_screenshot returns the path it actually used
saved = await tab.save_screenshot(filename=str(out_path), full_page=full_page)
saved_path = Path(saved if saved else out_path)
size = saved_path.stat().st_size if saved_path.exists() else 0
await output({
"path": str(saved_path),
"size_bytes": size,
"full_page": full_page,
}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Scroll the persistent tab.
Usage: scroll.py up — one viewport up
scroll.py down — one viewport down
scroll.py top — to page top
scroll.py bottom — to page bottom
scroll.py 500 — by 500 pixels (positive = down, negative = up)
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 1:
print('{"error": "usage: scroll.py [--headed|--headless] up|down|top|bottom|N"}')
return 2
arg = args[0]
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
if arg == "up":
expr = "window.scrollBy(0, -window.innerHeight * 0.9)"
elif arg == "down":
expr = "window.scrollBy(0, window.innerHeight * 0.9)"
elif arg == "top":
expr = "window.scrollTo(0, 0)"
elif arg == "bottom":
expr = "window.scrollTo(0, document.body.scrollHeight)"
else:
try:
n = int(arg)
except ValueError:
print(json.dumps({"error": f"bad arg {arg!r}"}, indent=2))
return 2
expr = f"window.scrollBy(0, {n})"
await js(tab, f"({expr}, true)")
await tab.wait(0.3)
state = await js(tab, """
({
scroll: window.scrollY,
max_scroll: document.body.scrollHeight - innerHeight,
at_top: window.scrollY === 0,
at_bottom: (window.innerHeight + window.scrollY) >= document.body.scrollHeight - 1
})
""")
await output({"action": arg, **state}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Select an option from a <select> dropdown by ref. Usage:
select.py REF "visible text or value"
select.py REF --index N
Matches by option.value first, then option.textContent (case-insensitive trim).
Dispatches input + change events so frameworks notice.
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode, REFS_FILE # noqa: E402
SELECT_JS = r"""
(sel, match, byIndex) => {
const el = document.querySelector(sel);
if (!el) return { ok: false, error: 'not found' };
if (el.tagName !== 'SELECT') return { ok: false, error: 'not a <select> element' };
const opts = Array.from(el.options);
let idx = -1;
if (byIndex !== null) {
idx = byIndex;
} else {
idx = opts.findIndex(o => o.value === match);
if (idx < 0) idx = opts.findIndex(o => o.textContent.trim().toLowerCase() === match.toLowerCase());
}
if (idx < 0 || idx >= opts.length) {
return { ok: false, error: 'no matching option', available: opts.slice(0, 20).map(o => ({ value: o.value, text: o.textContent.trim() })) };
}
const proto = Object.getPrototypeOf(el);
const setter = Object.getOwnPropertyDescriptor(proto, 'selectedIndex')?.set;
if (setter) setter.call(el, idx); else el.selectedIndex = idx;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true, selected_value: opts[idx].value, selected_text: opts[idx].textContent.trim(), index: idx };
}
"""
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 2:
print('{"error": "usage: select.py [--headed|--headless] REF VALUE or REF --index N"}')
return 2
ref = args[0]
by_index = None
match = None
if args[1] == "--index":
if len(args) < 3:
print('{"error": "--index requires a number"}')
return 2
by_index = int(args[2])
else:
match = args[1]
if not REFS_FILE.exists():
print(json.dumps({"error": "no snapshot yet — run snapshot.py first"}))
return 1
refs = json.loads(REFS_FILE.read_text())
selector = refs.get(ref)
if not selector:
print(json.dumps({"error": f"ref {ref!r} not in last snapshot"}))
return 1
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
expr = f"({SELECT_JS})({json.dumps(selector)}, {json.dumps(match)}, {json.dumps(by_index)})"
result = await js(tab, expr)
await output({"ref": ref, "selector": selector, **(result or {})}, browser=browser)
return 0 if (result and result.get("ok")) else 1
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Snapshot the persistent tab: page text + interactive elements with stable refs.
Writes the {ref: selector} map to /tmp/nodriver-skill/refs.json so subsequent
click.py / type.py / press.py calls can resolve refs into selectors.
Output format:
{
"url": "...",
"title": "...",
"text": "first 8000 chars of body innerText",
"refs": [{ref: "r1", tag: "a", name: "...", href: "...", visible: true, ...}, ...],
"tabs_open": 1
}
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import ( # noqa: E402
attach, get_persistent_tab, output, pop_launch_mode, REFS_FILE, STATE_DIR,
)
from snapshot import take_snapshot, build_selector_map # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if args:
print(json.dumps({"error": "usage: snapshot.py [--headed|--headless]"}, indent=2))
return 2
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
snap = await take_snapshot(tab)
STATE_DIR.mkdir(parents=True, exist_ok=True)
REFS_FILE.write_text(json.dumps(build_selector_map(snap)))
await output(snap, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = []
# ///
"""Explicitly start the nodriver daemon. Usage: start_daemon.py [BROWSER_OPTIONS]"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import ( # noqa: E402
ensure_daemon, is_daemon_alive, pop_launch_mode, running_launch_mode,
running_profile, running_no_sandbox, default_launch_mode, launch_profile,
launch_no_sandbox, PORT, PID_FILE,
)
def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"ok": False, "error": str(e)}, indent=2))
return 2
if args:
print(json.dumps({
"ok": False,
"error": "usage: start_daemon.py [BROWSER_OPTIONS]",
}, indent=2))
return 2
was_alive = is_daemon_alive()
try:
pid = ensure_daemon(mode=mode)
except Exception as e:
print(json.dumps({"ok": False, "error": str(e)}, indent=2))
return 1
no_sandbox = running_no_sandbox()
print(json.dumps({
"ok": True,
"pid": pid,
"port": PORT,
"mode": running_launch_mode() or mode or default_launch_mode(),
"profile": running_profile() or launch_profile(),
"no_sandbox": no_sandbox if no_sandbox is not None else launch_no_sandbox(),
"already_running": was_alive,
"pid_file": str(PID_FILE),
}, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""Cheap status read of the persistent tab. No navigation, no DOM mutation."""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if args:
print(json.dumps({"error": "usage: state.py [--headed|--headless]"}, indent=2))
return 2
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
state = await js(tab, """
({
url: location.href,
title: document.title,
ready_state: document.readyState,
scroll: window.scrollY,
text_len: (document.body && document.body.innerText || '').length,
viewport: { w: innerWidth, h: innerHeight }
})
""")
await output(state, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""Daemon health check + tab list. Returns JSON."""
import asyncio
import json
import os
import sys
import time
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import ( # noqa: E402
PID_FILE, PORT, is_daemon_alive, _read_pid, _process_alive,
attach, list_tabs, output, pop_launch_mode, running_launch_mode, running_profile,
running_no_sandbox,
)
def _proc_info(pid: int) -> dict:
info: dict = {"pid": pid}
# /proc/<pid>/stat for uptime, /proc/<pid>/status for RSS
try:
stat = Path(f"/proc/{pid}/stat").read_text().split()
# Field 22 (0-indexed 21): starttime in clock ticks since boot
starttime_ticks = int(stat[21])
clk_tck = os.sysconf(os.sysconf_names["SC_CLK_TCK"])
with open("/proc/uptime") as f:
system_uptime = float(f.read().split()[0])
proc_uptime_s = system_uptime - (starttime_ticks / clk_tck)
# In containerized/PRoot environments the clock can jump, producing
# negative or absurd values. Only report if it looks sensible.
if 0 <= proc_uptime_s < 365 * 24 * 3600:
info["uptime_s"] = round(proc_uptime_s, 1)
except Exception:
pass
try:
for line in Path(f"/proc/{pid}/status").read_text().splitlines():
if line.startswith("VmRSS:"):
info["rss_kb"] = int(line.split()[1])
break
except Exception:
pass
return info
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if args:
print(json.dumps({"error": "usage: status.py [--headed|--headless]"}, indent=2))
return 2
alive = is_daemon_alive()
pid = _read_pid()
payload: dict = {
"alive": alive,
"port": PORT,
"mode": running_launch_mode(),
"profile": running_profile(),
"no_sandbox": running_no_sandbox(),
"pid_file": str(PID_FILE),
"pid": pid,
}
if pid is not None and _process_alive(pid):
payload["process"] = _proc_info(pid)
if not alive:
print(json.dumps(payload, indent=2))
return 0
# Daemon is up — also fetch the tab list.
browser = await attach(mode=mode)
payload["tabs"] = await list_tabs(browser)
await output(payload, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = []
# ///
"""Stop the nodriver daemon. Cleans state files and stale singleton locks."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import stop_daemon # noqa: E402
def main() -> int:
try:
stopped = stop_daemon()
except Exception as e:
print(json.dumps({"ok": False, "error": str(e)}, indent=2))
return 1
print(json.dumps({"ok": True, "stopped": stopped}, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""List every open tab in the daemon (index, url, title, target_id)."""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, list_tabs, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if args:
print(json.dumps({"error": "usage: tabs.py [--headed|--headless]"}, indent=2))
return 2
browser = await attach(mode=mode)
tabs = await list_tabs(browser)
await output({"tabs": tabs}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Type text into an input/textarea/contenteditable by ref. Usage: type.py REF TEXT
The field is cleared first, then the text is set, then `input` and `change`
events are dispatched so frameworks (React/Vue/etc.) notice. For
contenteditable elements, innerText is set instead of value.
Use press.py for individual key events (Enter, Tab, ...).
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode, REFS_FILE # noqa: E402
TYPE_JS = r"""
(sel, text) => {
const el = document.querySelector(sel);
if (!el) return { ok: false, error: 'not found' };
el.focus();
if (el.isContentEditable) {
el.innerText = text;
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
return { ok: true, kind: 'contenteditable', value: el.innerText };
}
if ('value' in el) {
// Use the prototype setter so React's synthetic event system sees it.
const proto = Object.getPrototypeOf(el);
const setter = Object.getOwnPropertyDescriptor(proto, 'value') &&
Object.getOwnPropertyDescriptor(proto, 'value').set;
if (setter) setter.call(el, text); else el.value = text;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true, kind: el.tagName.toLowerCase(), value: el.value };
}
return { ok: false, error: 'element has no value or contenteditable' };
}
"""
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 2:
print('{"error": "usage: type.py [--headed|--headless] REF TEXT"}')
return 2
ref, text = args[0], args[1]
if not REFS_FILE.exists():
print(json.dumps({"error": "run snapshot.py first"}, indent=2))
return 1
refs = json.loads(REFS_FILE.read_text())
selector = refs.get(ref)
if not selector:
print(json.dumps({"error": f"unknown ref {ref!r}"}, indent=2))
return 1
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
# Inject the helper, then call it. We can't pass arguments to evaluate()
# cleanly, so we inline both via JSON-encoded literals.
expr = f"({TYPE_JS})({json.dumps(selector)}, {json.dumps(text)})"
result = await js(tab, expr)
await output({"ref": ref, "selector": selector, **(result or {})},
browser=browser)
return 0 if (result and result.get("ok")) else 1
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Set files on a <input type="file"> element by ref. Usage: upload.py REF FILE [FILE...]
The ref must point to an <input type="file"> from the latest snapshot.
Files must be absolute paths to existing local files.
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode, REFS_FILE # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if len(args) < 2:
print('{"error": "usage: upload.py [--headed|--headless] REF FILE [FILE...]"}')
return 2
ref = args[0]
files = [str(Path(f).resolve()) for f in args[1:]]
# Validate files exist.
for f in files:
if not Path(f).is_file():
print(json.dumps({"error": f"file not found: {f}"}, indent=2))
return 1
if not REFS_FILE.exists():
print(json.dumps({"error": "no snapshot yet — run snapshot.py first"}, indent=2))
return 1
refs = json.loads(REFS_FILE.read_text())
selector = refs.get(ref)
if not selector:
print(json.dumps({"error": f"ref {ref!r} not in last snapshot"}, indent=2))
return 1
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
# Get a JS remote object id for the element.
resp = await tab.send(
__import__("nodriver").cdp.runtime.evaluate(
expression=f"document.querySelector({json.dumps(selector)})",
return_by_value=False,
)
)
remote_obj = resp[0] if isinstance(resp, tuple) else resp
if not remote_obj or not remote_obj.object_id:
await output({"error": f"ref {ref} no longer in DOM", "hint": "re-run snapshot.py"}, browser=browser)
return 1
# Verify it's a file input.
is_file_input = await js(tab, f"""
(() => {{
const el = document.querySelector({json.dumps(selector)});
return el && el.tagName === 'INPUT' && el.type === 'file';
}})()
""")
if not is_file_input:
await output({"error": f"ref {ref} is not an <input type='file'>", "selector": selector}, browser=browser)
return 1
# Set files via CDP.
import nodriver.cdp.dom as cdp_dom
await tab.send(cdp_dom.set_file_input_files(files=files, object_id=remote_obj.object_id))
# Dispatch change event so frameworks pick it up.
await js(tab, f"document.querySelector({json.dumps(selector)}).dispatchEvent(new Event('change', {{bubbles: true}}))")
await output({"ref": ref, "files": files, "count": len(files)}, browser=browser)
return 0
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["nodriver"]
# ///
"""
Block up to TIMEOUT seconds for an element (CSS selector) or text to appear.
Usage: wait.py "#login-button" — wait for selector
wait.py "Welcome back" --text — wait for substring in body text
wait.py "#foo" --timeout 60 — custom timeout (default 30s)
Returns when the condition is met OR when the timeout expires.
"""
import json
import os
import sys
import time
from pathlib import Path
os.environ.setdefault("UV_LINK_MODE", "copy")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from runner import attach, get_persistent_tab, js, output, pop_launch_mode # noqa: E402
async def main() -> int:
try:
mode, args = pop_launch_mode(sys.argv[1:])
except ValueError as e:
print(json.dumps({"error": str(e)}, indent=2))
return 2
if not args:
print('{"error": "usage: wait.py [--headed|--headless] SELECTOR_OR_TEXT [--text] [--timeout N]"}')
return 2
is_text = "--text" in args
timeout = 30
if "--timeout" in args:
i = args.index("--timeout")
try:
timeout = int(args[i + 1])
except (IndexError, ValueError):
print('{"error": "bad --timeout value"}')
return 2
args = args[:i] + args[i + 2:]
args = [a for a in args if a != "--text"]
needle = args[0]
browser = await attach(mode=mode)
tab = await get_persistent_tab(browser)
if is_text:
check_expr = (
f"document.body.innerText.indexOf({json.dumps(needle)}) !== -1"
)
else:
check_expr = f"!!document.querySelector({json.dumps(needle)})"
start = time.monotonic()
deadline = start + timeout
found = False
while time.monotonic() < deadline:
if await js(tab, check_expr):
found = True
break
await tab.wait(0.25)
elapsed = round(time.monotonic() - start, 2)
await output({
"needle": needle,
"kind": "text" if is_text else "selector",
"found": found,
"elapsed_s": elapsed,
"timeout_s": timeout,
}, browser=browser)
return 0 if found else 1
if __name__ == "__main__":
import nodriver as uc
uc.loop().run_until_complete(main())
Related skills
FAQ
How is state kept between calls?
One daemon runs one persistent tab; every script attaches to the same long-running browser, performs one action, and exits while the browser keeps going.
When should I not use it?
Not for static HTML (use WebFetch), one-shot searches (use WebSearch), JSON APIs (use curl), or a single non-interactive scrape.