Cross-checked AGENTS.md, README.md, SKILL.md, install.md, and profile-sync.md for accuracy. Tightened the sync_local_profile docstring and the chrome://inspect stderr message to match. Cloud-bootstrap test updated to set BU_AUTOSPAWN. All unit tests pass.
Domain skills auto-injected by goto_url() are community-contributed and quality varies; defaulting them off avoids polluting the average run while preserving the contribution loop. Set BH_DOMAIN_SKILLS=1 to opt in.
* refactor(tests): reorganize into tests/unit and tests/integration
Moves all root-level test_*.py files into a structured tests/ directory:
- tests/unit/ — admin, helpers (was test_screenshot), run
- tests/integration/ — js expression tests
- tests/conftest.py — shared fake_png pytest fixture, eliminating duplication
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: move to src layout, agent-workspace, and fix SKILL.md invocation format
- Move package to src/browser_harness/ and domain-skills/interaction-skills to agent-workspace/
- Fix all browser-harness <<'PY' heredoc examples in SKILL.md and run.py HELP string to use the correct -c '...' flag format (heredoc was never supported by the CLI)
- Update SKILL.md path references from domain-skills/ to agent-workspace/domain-skills/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Markdown-style domain skill for Polymarket scraping via Gamma API
(api-first per repo doctrine) with DOM leaf-div-disambiguation fallback
for CSS-module SPAs. Covers market outcomes, metadata, and comments.
Live-tested against gamma-api.polymarket.com and a live event page:
- 9 outcomes extracted (e.g. April 7: YES 99.95 / NO 0.05, vol $45.7M)
- Metadata: title, end_date, total_volume, category, market_count
- 38 comments fetched (40 raw, 2 deleted skipped)
Gotcha documented: Gamma API comment envelopes for deleted comments
preserve id/createdAt/profile/media/parentCommentID but drop the body
field entirely — naive dict access throws KeyError. Guard with
'if "body" not in c: continue'.
DOM fallback pattern documented (not primary path): Polymarket has
zero data-testid attributes and CSS-module-hashed classes. Leaf-div
disambiguation (children.length === 0 + nearest-common-ancestor
grouping) is the only robust approach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SKILL.md now covers day-to-day usage only. Maintenance commands
(--doctor, --setup, --update) and the architecture section move to
install.md where setup and break-fix content belongs.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- goto() → goto_url()
- click() → click_at_xy()
- screenshot() → capture_screenshot()
These three shared exact names with Playwright but different argument
shapes, causing agent confusion. Updated helpers.py and all markdown
skill files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(run): pass globals to exec so comprehensions resolve free vars
exec(sys.stdin.read()) inside main() passes different dicts for globals
and locals. Python comprehensions and generator expressions compile to a
nested function whose free-variable lookups can only see the globals
dict, so code like
for it in items:
low = it['text'].lower()
hit = any(k in low for k in KEYS) # NameError: 'low'
fails at module level under exec. `low` is stored in the exec locals
dict which the generator's implicit function cannot see.
Passing globals() as the only extra arg makes exec use the same dict
for both globals and locals, and comprehensions resolve cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(skill): eyeball click coords from screenshots; no getBoundingClientRect
The previous Clicking bullet said "screenshot -> look -> click(x,y)" but
did not rule out roundtripping through js("...getBoundingClientRect()")
to compute coords. Agents coming from Playwright / Selenium habits tend
to locate-first-click-second even when the screenshot already shows the
target, which is slower and more brittle (hidden inputs placed at
x=-9999, CSS-transformed elements, pseudo-elements) than just reading
the pixel off the image.
Rewrite the bullet to explicitly suppress that reflex and scope the
DOM-fallback to elements with no visible geometry. Also replace the
loose "compositor level" phrasing with the actual mechanism: Chrome's
browser-process hit-testing, which is why clicks pass through iframes
/ shadow DOM / cross-origin without extra work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add --version, --doctor, --setup, --update[-y] commands in run.py;
logic lives in admin.py (install-mode detection, GitHub-releases cache
with 24h TTL, dirty-worktree guard, interactive Chrome-attach flow).
- Print a once-per-day startup banner telling agents to run
`browser-harness --update -y` when a newer release is available.
- Rename project to browser-harness in pyproject.toml so PyPI installs
(uv tool install browser-harness) work via the public package name.
- Add .github/workflows/release.yml: on v* tag push, verify the tag
matches pyproject.toml, uv build, and publish to PyPI via trusted
publishing.
- Wire helpers.http_get through fetch_use.fetch_sync(...).text when
BROWSER_USE_API_KEY is set; falls back to the original urllib path
otherwise, preserving the existing str return contract.
- Document the new commands and the agent's self-update duty in
SKILL.md and install.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Fast start snippet used https://browser-use.com purely as an
illustrative "hello world". Pointing it at docs.browser-use.com
gives agents a more useful landing page on their first run.
Refs #102
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* remote: Python API for remote browsers, profiles, and local-profile sync
No CLI, no new entrypoint. Every helper is a Python function callable from
inside a normal `browser-harness <<'PY'` block. run.py pre-imports them.
admin.py:
- start_remote_daemon(name, profileName=None, **create_kwargs)
Now forwards every documented POST /browsers kwarg (profileId, profileName,
proxyCountryCode, timeout, customProxy, browserScreenWidth/Height, ...).
profileName is resolved client-side via list_cloud_profiles — no browser-use
API change needed. Prints liveUrl and auto-opens it locally when a GUI is
detected (macOS/Windows always; Linux needs $DISPLAY / $WAYLAND_DISPLAY);
headless servers print only.
- list_cloud_profiles() — GET /api/v3/profiles + per-profile detail; returns
[{id, name, cookieDomains, lastUsedAt, userId}]. Agents should report
len(cookieDomains) not the full list — profiles can have 500 cookies across
dozens of domains.
- list_local_profiles(), sync_local_profile(name) — shell out to `profile-use`.
sync_local_profile returns the newly-created cloud UUID.
Profile-sync skill rewritten Python-first with the chat-driven flow (ask the
user which profile; summarize by domain count, never dump cookies) and calls
out the two upstream limitations (sync always creates a new cloud profile; no
per-domain filtering) that need a PR to browser-use/profile-use — they can't
be fixed in browser-harness because the Browser Use API has no cookie
upload/download endpoint.
SKILL.md remote-browsers section updated to match, leading with the parallel
sub-agent use case.
* remote: fix misleading 'no GUI' message when webbrowser.open raises
cubic flagged this on #84: if _has_local_gui() is True but webbrowser.open
raises (e.g. no default browser configured), the code fell through to the
final 'no local GUI — share the liveUrl' line, which is wrong on both counts.
Restructure so each branch produces exactly one accurate message.
* skill: make PATH invocation and new_tab-at-session-start unmissable
Two footguns that agents keep hitting on their first call:
1. They prefix the harness with `cd /path/to/browser-harness && uv run …`
even though `browser-harness` is installed on `$PATH` as a standalone
entrypoint. `uv run` from the wrong cwd actively fails, and the `cd`
bakes a brittle assumption about where the repo lives.
2. They `goto(url)` on the first call, which navigates the user's
currently-active tab and destroys whatever they were doing.
Fast start now:
- uses `browser-harness <<'PY'` (no `uv run`) in the example
- uses `new_tab(url)` instead of `goto(url)`
- adds an explicit two-point callout explaining *why* each rule matters
- mirrors both rules in the "What actually works" bullet list so a
scanning agent sees them even if they skip the intro
- drops the stray `uv run` from the remote-browser snippet
* skill: trim Fast start callout; add read-before-edit note
* skill: require reading full file before using the harness, not just editing
* install: error-driven decision tree, drop unconditional chrome://inspect
The previous bootstrap implied that every attach failure (and any
not-running-Chrome case) needed a chrome://inspect navigation. In
practice the remote-debugging checkbox is per-profile sticky in Chrome,
so for any profile that has ever had it toggled on, just launching
Chrome and polling is enough — chrome://inspect is only needed the
first time per profile, when DevToolsActivePort is genuinely missing.
Restructure step 3 of install.md as an explicit error-keyed decision
tree (no Chrome process / DevToolsActivePort missing / port not live
yet / stale websocket) and add a matching gotcha to SKILL.md. Also fix
a stale `uv run bh` snippet in SKILL.md — the entrypoint is
`browser-harness`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Update install.md
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
* Update install.md
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
daemon.py: Page/DOM/Runtime/Network.enable calls now have a 5s
timeout. Previously they could hang indefinitely on heavy pages
(TikTok FYP), preventing the daemon from reaching its socket
listener.
SKILL.md: added one gotcha for when restart_daemon() itself hangs
(kill Chrome entirely and reconnect).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Reframe post-task ritual as the default "contribute back" procedure
Renames the section to "Always contribute back" and turns the guidance
into an imperative default. Adds concrete examples of what's worth a PR
(private APIs, framework quirks, stable selectors, URL patterns, waits,
traps), a schema for what a domain skill should capture (the durable
shape of the site, not the run narration), and an explicit do-not-write
list — most importantly banning raw pixel coordinates.
Narrows the scope to `domain-skills/` contributions only; no longer
nudges agents to update `interaction-skills/` or `helpers.py` as part
of this loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Replace "hesitate" with clearer cost framing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix daemon attaching to invisible omnibox popup on fresh Chrome
When Chrome opens fresh, the only page targets are chrome://
internal pages and the omnibox popup (1px invisible viewport).
The daemon's attach_first_page() fell back to the popup, making
all subsequent work invisible to the user.
Fix: when no real pages exist, create an about:blank tab via
Target.createTarget instead of attaching to the omnibox popup.
Tested configurations:
- Fresh start with no real tabs → creates about:blank (1112x817)
- Navigate without AppleScript → works, tab visible
- Recovery from stale socket → auto-reconnects
- Chrome restart from scratch → creates about:blank
Also adds interaction-skills/connection.md documenting the
omnibox popup problem and startup sequence.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* add connection skill reference to main SKILL.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* add remote browser support via Browser Use cloud + multi-daemon
HARNESLESS_NAME suffixes socket/pid/log — daemons are independent, no
supervisor. start_remote_daemon() creates a Browser Use cloud browser and
launches a daemon attached to it; kill_daemon() stops both. Local Chrome
path is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* rename env vars to BU_ prefix (shorter, less noisy in tool calls)
HARNESLESS_NAME → BU_NAME
HARNESLESS_CDP_WS → BU_CDP_WS
HARNESLESS_REMOTE_BROWSER_ID → BU_BROWSER_ID
Socket/pid/log files keep the harnesless- prefix on disk so they're
recognizable in /tmp. BROWSER_USE_API_KEY unchanged (external convention).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* rename project from harnesless to bu
Socket/pid/log paths now /tmp/bu-<name>.{sock,pid,log}. pyproject package
name updated, uv.lock regenerated. Slash command now /bu.
Note: the repo directory itself is still named harnesless on disk. Rename
manually (mv harnesless bu) so the absolute paths in docs line up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit added fill_form / mui_select_first / is_success which are
eval-harness logic, not harnesless primitives (task-specific defaults like
"dumbledore"/"Harry Potter", MUI-only shim). Per AGENTS.md — "could the LLM
rewrite this from scratch after reading it once" — the LLM should pick field
values + the submit strategy per-task, not inherit eval defaults.
Removed: fill_form, _FILL_JS, mui_select_first, is_success (~100 lines)
Kept: dispatch_key, upload_file, capture_dialogs/dialogs (universal needs)
Insights from the eval captured as gotchas in SKILL.md instead:
- React controlled inputs need native-setter + input event
- Radios/checkboxes: el.click() over el.checked=true for React
- MUI / UI-library overlays: real CDP click, not JS .click()
- CDP char event ≠ DOM keypress for special keys → dispatch_key
- Same-origin iframes: contentDocument walk, not CDP targets
- Shadow DOM: querySelector doesn't pierce, walk .shadowRoot
- Form success signals vary: element / alert / body text
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Connection management (addresses "shit ton of daemons" problem):
- Socket is the lock. Daemon refuses to start if another is already
listening (5-line check).
- PID file at /tmp/harnesless.pid written on start, removed on exit.
- ensure_daemon() / kill_daemon() / daemon_alive() helpers.
- run.py auto-calls ensure_daemon() before exec — users never manage
the daemon manually.
- kill_daemon uses PID file (robust) instead of pkill pattern matching
(was silently missing because the process command line didn't contain
the "harnesless/" prefix).
Post-task ritual added to SKILL.md: after every browser task, extract
ONE generalizable friction point and make the simplest possible
improvement (2-line helper, one-line gotcha, recipe correction).
This is how the harness sharpens itself over time.
Verified end-to-end: kill_daemon → 0 processes + files cleaned → next
run.py → auto-starts exactly one daemon. Second `uv run daemon.py`
exits with "daemon already running" message instead of racing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Observed friction during an Azure portal task:
- Daemon's default session went stale (user closed the attached tab),
which broke every subsequent call including browser-level Target.*.
- new_tab() had been removed in the previous simplification pass but
was needed to recover.
- Azure portal renders blade panels in iframes; js() on the main page
returned nothing for picker contents.
Changes:
- daemon: browser-level Target.* calls now bypass self.session entirely
(so a stale session doesn't poison them). On "Session with given id
not found" for session-scoped calls, clear + re-attach + retry once.
Merged start() and the new recovery path into attach_first_page().
- helpers: add new_tab(); js() accepts target_id for iframe queries;
iframe_target(substr) to find blade iframes; ensure_real_tab() now
resilient to stale-session exceptions.
- SKILL.md: one-line note on iframe-site workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Session of 7 real tasks (Upwork, X, Google Flights, HN, Netflix, iPhone
comparison) used only half the original helper surface — every DOM
interaction went through js() + a bespoke selector, never through the
indexed-DOM helpers. Dropped 14 that never fired: get_dom,
click_element, type_in, element_pos, save_cookies, load_cookies,
set_viewport, screenshot_full (folded into screenshot full=True),
double_click, right_click, move_mouse, new_tab, close_tab,
handle_dialog, back, reload.
Also:
- daemon: shorter identifiers, collapsed boilerplate, TOCTOU fix via
try/except on read_text, is_real_page module-level, no variable
shadowing of `url`.
- helpers: one shared INTERNAL_URL_SCHEMES tuple instead of three
subtly-different ones; current_tab() collapsed from 4 round-trips
with dead fallback to one; wait_for_load() stops draining the shared
event buffer.
- SKILL.md / AGENTS.md: cut recipes and anything the LLM already knows
(what click does, what CDP is). Kept only project-specific context.
Net: 8600 -> 4030 tokens across the project (53% reduction); the file
the LLM reads every skill invocation (helpers.py) dropped from 2193
to 1219 tokens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three-process architecture: daemon.py holds one persistent CDP WebSocket
to the user's running Chrome (via chrome://inspect), short-lived run.py
processes talk to it over a Unix socket, helpers.py is the transparent
layer the LLM reads and edits at will.
Philosophy: no CLI, no fixed API surface. The LLM writes Python blocks
against ~13 tiny helpers (cdp, click, type_text, screenshot, get_dom,
etc.) and edits helpers.py on the fly when a pattern repeats. Coordinate
clicks default because they pass through iframes/shadow DOM/cross-origin
at the compositor level.
Uses cdp-use internally for send_raw only (ignores its 36k lines of
typed wrappers — raw CDP strings tokenize better than typed calls).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>