The merged tab indicator only marked on Page.loadEventFired,
but Page events weren't enabled for new sessions created by
switch_tab(). Added Page.enable + immediate mark in set_session
handler so the 🟢 appears on every tab switch and survives
goto() navigation.
6/6 tests pass: new_tab, switch_tab, goto, click link,
cmd+click, switch back.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add Microsoft Edge support
Edge is Chromium, so the daemon's CDP bootstrap works unchanged once it
knows where Edge's DevToolsActivePort lives. This adds the macOS, Linux,
and Windows Edge profile paths to the discovery list, plus a one-line
note in install.md clarifying that `chrome://inspect/#remote-debugging`
and the rest of the setup flow apply to Edge too.
* Edge Beta/Dev/Canary, drop Edge from main README
Cover the non-stable Edge channels on macOS, Linux, and Windows so
users on insider builds don't fall through to 'DevToolsActivePort not
found'. Edge SxS (Side-by-Side) is Canary's install dir on Windows.
Keep README's Chrome-only pitch; Edge stays documented in install.md
where setup details belong.
* install.md: move Edge note to bottom, one line
Top-of-section carve-out distracts from the Chrome bootstrap steps.
A one-liner in the cold-start reminders is enough for users who need it.
---------
Co-authored-by: MagMueller <mamagnus00@gmail.com>
Each retry created a new CDPClient which opened a new WebSocket
connection, triggering Chrome's "Allow debugging" dialog again.
12 retries = 12 stacked popups the user has to dismiss.
Now tries once. If it fails, tells the user to click Allow and
retry — no popup spam.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.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>
Agents rarely know to check for open alert/confirm/prompt/beforeunload
dialogs — they freeze the JS thread, so page_info()'s Runtime.evaluate
returns a stale or misleading viewport dict while the page is blocked.
Track the latest unhandled Page.javascriptDialogOpening in the daemon
(cleared on Page.javascriptDialogClosed) and, when one is open, return
{dialog: {type, message, ...}} from page_info() instead of the usual
viewport dict. The dialogs skill now points agents at this signal.
* 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>