- eval now prints `eval @ <url>` to stderr (stdout stays the raw value) so an
agent can catch tab drift — e.g. a logged-in fetch that hit the wrong origin —
before trusting the result. Mitigates the issue #2/#3 P0 safety concern. (eval
already returned the origin; the default output just never surfaced it.)
- `type --focused <text>`: type into the currently-focused element with no
selector, for custom widgets that move focus to a hidden input (issue #2 P3).
- AGENT_BROWSER_HUMANIZE set to an unrecognized value now warns once (like the
--humanize flag) instead of being silently ignored (Hermes #3).
A coordinate click resolved from a CSS selector (incl. the getByText/find path's
located node) skipped the occlusion check that @ref clicks already get, so an
overlay on top made the click land on the overlay while still reporting ✓ Done —
the worst failure mode for an agent (Hermes #1, issue #2/#3). Now: if the click
point doesn't hit the target (elementFromPoint isn't the element / a descendant /
an ancestor wrapper), dispatch through the DOM instead, which fires the real
handler. Best-effort probe (a flaky check never blocks the normal path); skipped
for strict CLICK_MODE=coord and non-left/multi-clicks.
Verified: occluded button click hits 0→1 (was silent ✓Done); normal click
unaffected.
Root cause behind Hermes #1 (CLICK_MODE=dom "does nothing") and #2 (--humanize
"does nothing"): both are env vars the daemon reads, but the daemon's env is
frozen at spawn — set them on a command to an already-running daemon and they
were silently ignored. (Confirmed: setting CLICK_MODE=dom at daemon spawn made
dom_click fire; setting it later did not.)
Fix: the client forwards AGENT_BROWSER_CLICK_MODE / AGENT_BROWSER_HUMANIZE in the
command envelope (_clickMode/_humanize); execute_command applies them per command
— mirrors CLICK_MODE into the process env (interaction::click reads it fresh) and
sets the humanize session level. Each command is authoritative.
Verified on an already-running daemon: CLICK_MODE=dom now fires dom_click
(hits 0→1); --humanize human typing applies.
- `eval --file <path>`: read JS from a file, sent verbatim — avoids shell-mangling
of non-ASCII identifiers/strings (Chinese), quotes, and large scripts (issue #3).
- `tab list`: truncate multi-KB URLs (JWT/OTP login links) middle-out with a char
count so the list stays readable (issue #3).
- skill: fix the snapshot example to match real output
(`- role "name" [ref=eN]`, not `@e1 [role]`); document that eval runs in the
page MAIN world with persistent state (top-level `const` collides — use IIFE /
window / unique names) and to prefer --file/--stdin/-b for non-ASCII or big JS.
When connected to the user's real Chrome, mgr.close() disconnected but never
closed the tabs the session opened — so every session (especially one that
failed before calling close, or a forgotten one) left its tabs piling up in the
user's browser. Idle-timeout and shutdown have the same exit path.
Track the target_ids this session creates via Target.createTarget in
`created_targets` (only ever our own tabs — never the user's existing tabs, which
the raw-CDP path attaches to, nor other sessions'). On close(), for the connected
path (not a launched browser, which Browser.close handles wholesale), close each
of those targets — the extension maps Target.closeTarget → chrome.tabs.remove.
Verified against a throwaway --cdp Chrome: open + 2 `tab new` → 3 pages; `close`
→ back to 1 (our 2 closed, the pre-existing tab untouched).
fork.40 errored after 5s ("reload the extension"), which still pushed the problem
onto the user. Extend the relay-reconnect wait to ~15s when the extension is
installed: enough for the MV3 service worker to wake and reconnect on its own
(onStartup after a Chrome restart, or the keepalive alarm). The loop re-checks
the relay file each iteration, so a mid-wait recovery is picked up instantly and
the full window is only spent when the extension is genuinely down. End users no
longer have to do anything when the relay blips.
Root cause of the recurring "Allow remote debugging?" dialog: when the ab-connect
relay was momentarily down (MV3 service worker drops the relay-url file across a
Chrome restart / idle wake), auto_connect_cdp silently fell through to the raw
:9222 DevToolsActivePort path — which pops Chrome 136+'s consent modal, the exact
thing the extension exists to avoid. Even a relay-aware build hit this if it
connected during the blip.
Fix: if the native-messaging host is installed (connect::host_installed() — the
durable signal that the user chose the extension path), auto_connect retries the
relay for ~5s while the SW reconnects, and then ERRORS with an actionable message
instead of attaching to a raw debug port. The raw :9222 path now runs only when
no extension is set up (where the dialog is expected). `--cdp <port>` still forces
the raw path explicitly.
The JSON-array branch of parse_curl_cookies dropped every field except
name/value, so importing a full cookie export (httpOnly session tokens,
per-domain cookies spanning multiple hosts, secure/sameSite/expiry) could
not reconstruct a usable auth state — a single --domain override cannot
cover an export that spans .chatgpt.com, .openai.com, etc.
Pass through url/domain/path/secure/httpOnly/sameSite/expires when present,
accepting common aliases from DevTools / EditThisCookie exports
(http_only, same_site, no_restriction, expirationDate). Bare {name,value}
exports are unchanged. Added a round-trip test.
Driving the user's real Chrome should not yank their view around. Now the agent
operates entirely in the background:
- New tabs are created with `background: true` (CreateTargetParams) so opening
one never foregrounds it (the ab-connect extension already used active:false;
this covers the raw-CDP path too).
- Dropped the two AUTO `Page.bringToFront` calls (auto-connect fresh tab, and the
internal active-page switch). The explicit `bringToFront` command is untouched —
surfacing a tab stays opt-in.
- enable_domains now sets `Emulation.setFocusEmulationEnabled(true)` so a
backgrounded agent tab still renders (screenshots work), isn't render-throttled,
and reports document.hasFocus()/visibilityState='visible' — which also removes
the "tab is hidden the whole session" bot tell.
Verified headless: hasFocus=true/visible while backgrounded; click + screenshot
still work. Default behaviour, no flag.
Completes the humanize suite:
- Clicks land on a jittered point inside the element's box (Fast/Human) instead
of its exact centre. `resolve_element_center` now also returns the element
width/height (box_model_dims); the CSS-selector path reports zero size → land
on centre (no jitter, no regression). Jitter is clamped to the inner box so the
click never misses.
- Wheel scrolls split into eased, jittered segments (humanize::scroll_segments,
unit-tested) instead of one instant jump.
- Drag follows the curved trajectory at Fast/Human (linear 10-step at Off).
Off is unchanged throughout. 9/9 unit tests; verified headless — jittered click
still lands (→ iana.org), segmented scroll moves the page.
- Typing: type_text_into_active_context now uses variable, human-like
inter-keystroke gaps from humanize::keystroke_delays when no explicit --delay
is given (Fast/Human); Off stays instant. Explicit --delay still wins.
- CLI: `--humanize off|fast|human` surfaces AGENT_BROWSER_HUMANIZE so the
session's daemon (a child that inherits this env) applies it, overriding the
adaptive detector. Invalid values warn and are ignored.
Verified headless: `--humanize human` + type lands "hello world" correctly.
Deferred: in-bbox landing jitter (helper ready, needs bbox threaded) + wheel/drag
easing.
After each navigation, probe the loaded page for known behavioural anti-bot
vendor fingerprints — cookies (_abck/Akamai, _px/PerimeterX, datadome,
reese84/Imperva, …), script URLs, and window globals — and escalate this
session to HumanizeLevel::Human when one is present, else fall back to the Off
baseline. So ordinary sites run at full speed (instant clicks) and only pages
actually guarded by behavioural detection pay for human-like motion.
`AGENT_BROWSER_HUMANIZE` still forces a fixed level and short-circuits the probe.
Best-effort: a failed probe leaves the level unchanged. Verified end-to-end
(headless --launch): a HUMANIZE=human click on example.com traverses the curved
trajectory and lands correctly (→ iana.org), identical outcome to Off.
Behavioural stealth: a click that teleports the cursor to an element's exact
centre with no approach path and zero press/release delay is a tell that
advanced anti-bot vendors (Akamai/PerimeterX/DataDome) flag, even though our CDP
events are isTrusted.
New `native::humanize` module — pure, unit-tested motion maths (cubic-Bézier
eased trajectories, in-bounds landing jitter, variable keystroke cadence, and an
anti-bot vendor detector) plus a small daemon-wide runtime (current level + last
cursor + per-action seed). `dispatch_click` now moves along a curved,
decelerating path from the last cursor position and dwells before releasing.
Three levels off|fast|human. Default is Off → byte-for-byte the old teleport, so
nothing changes until opted in. `AGENT_BROWSER_HUMANIZE=human` forces it now;
the adaptive per-navigation detector (set_detected_level) and type/wheel/drag
coverage land next. 8/8 unit tests; fmt + clippy clean.
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
Ships the post-fork.34 skill updates into the binary's embedded `skills get core`:
- lead extension setup with the one-click Chrome Web Store install
- nudge agents to file UX feedback at the GitHub issues page
- when an agent hits the "Allow remote debugging?" dialog, self-check the version
and upgrade a stale (<fork.30) build / remove a shadowing npm-pnpm copy
Plus the README opening rewrite (hook-first vs Claude-in-Chrome / web-access /
Playwright) and Chinese README — docs only, ride along.
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
Ships the post-publish changes now that agent-browser-stealth is live on the
Chrome Web Store (knfcmbamhjmaonkfnjhldjedeobeafmk):
- force-install (.mobileconfig) targets the Store extension id (5d202c0)
- skill leads extension setup with the one-click Store install; agents that hit
the "Allow remote debugging?" dialog now tell the user to install the Store
build instead of retrying the raw-port path (bc96229)
- native-messaging host already allow-lists both the Store and Load-unpacked ids
agent-browser-stealth is now published (id knfcmbamhjmaonkfnjhldjedeobeafmk). The
.mobileconfig force-install pulls from the Web Store update server, which serves
the extension under its STORE id — so the forcelist must use STORE_EXTENSION_ID,
not the local Load-unpacked id. (The native-messaging host already allows both
ids.)
e2e_save_state_cross_domain navigated to httpbin.org as "domain A", which is an
unreliable external service — when it was slow/unreachable in CI the page didn't
load on that origin, so its localStorage origin was missing from the saved state
and the test failed intermittently. Cookies/localStorage are set client-side via
CDP, so the page just needs to load reliably: use example.org (IANA-reserved,
like example.com) instead. Match full hostnames so the two example.* origins
don't alias. Verified locally: passes deterministically.
The Rust (windows) matrix job hung for hours (GitHub's 6h default) because the
`doctor_offline_quick_json_emits_valid_payload` integration test spawns the real
CLI and `doctor --offline --quick` does not exit on Windows while its stdout is
captured — so `Command::output()` blocks forever. (The 767-test main suite and
the `doctor --help` test both pass on Windows; only this check hangs. macOS/Linux
matrix is unaffected.) This was masked until now because fail-fast used to cancel
the Windows job whenever the macOS lightpanda test failed first.
- skip that one test on Windows (`#[cfg_attr(windows, ignore = …)]`) with a note
to investigate the Windows doctor exit/pipe behavior; still runs on Linux/macOS.
- add `timeout-minutes: 30` to the rust-cross matrix and native-e2e jobs so a
hung test fails fast with a readable log instead of running to the 6h default.
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
- invalid CSS selector now errors "Invalid selector '<sel>': <reason>" instead of
the misleading "Element not found" — the coordinate path (resolve_by_selector)
now also inspects exception_details, matching resolve_element_object_id.
- `wait --url ""` is rejected at parse time ("needs a non-empty pattern") rather
than silently matching any URL. Unit test added.
Not changed: verb-less `find role X` defaulting to a click. That default is a
deliberate, tested decision (test_find_role_default_subaction_click_when_no_action);
changing it to locate-and-report is a design choice left to the maintainer.
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
- wait --url: the arg parser never read `--timeout`, so a non-matching pattern
waited the large default and wedged the daemon. Parse it. Also: matching was a
literal substring (`includes`) so globs never matched — convert `**`/`*`/`?`
globs to an anchored regex. And `poll_until_true` now bounds each probe with a
timeout and tolerates transient navigation errors, so a hung `Runtime.evaluate`
can never block past the deadline (un-wedges the daemon).
- find role <role> [--name]: the query was `[role="X"], X`, which matches a
literal <X> tag / explicit attribute but NOT implicit-role elements — so
`find role link` (<a href>) and `find role heading` (<h1>) never matched. Add a
proper ARIA-role → implicit-element map and broaden accessible-name matching
(aria-label/title/alt/value/text).
- click on a syntactically-invalid selector returned `✓ Done`: querySelector
throws, and Runtime.evaluate returned the thrown DOMException as an objectId
that was clicked as if it were the element. Check exception_details → error.
- output: a title-less page now prints `✓ <url>` instead of an empty title line.
- docs(skill): tab refs are `t2`, not `2` (SKILL.md, electron).
Verified live (isolated launch): wait --url glob matches instantly; non-matching
honors --timeout (2s) and leaves the daemon responsive; find role link/heading
match; invalid selector errors. Unit tests added for the glob + role map + parse.
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
- extension: popup status page (paired/not-paired) so the listing has standalone
UI; renamed agent-browser-stealth + new icon (earlier in this line)
- store: upload zip strips manifest "key" (the Web Store forbids it); the unpacked
dir + .crx keep it. Submitted for review (item knfcmbamhjmaonkfnjhldjedeobeafmk).
- connect: native-messaging allowed_origins lists BOTH the local Load-unpacked id
(ciiljdlhd…) and the store-assigned id (knfc…), so either install path pairs.
- ci: launch-based jobs opt into AGENT_BROWSER_ALLOW_HEADLESS for display-less
runners (fixes Native E2E); + version-sync/dashboard/fmt/clippy/flaky-test repairs.
- docs: skill documents both install methods (Load unpacked now, Web Store later).
Store upload strips manifest 'key', so the published build gets id
knfcmbamhjmaonkfnjhldjedeobeafmk (not the local ciiljdlhd). Add a
STORE_EXTENSION_ID const and list both origins in allowed_origins so either the
local Load-unpacked build or the store build can reach the native host.
CI's stable toolchain is clippy 1.96, which flags unnecessary_sort_by that local
1.94 did not. hits.sort_by(|a,b| b.date_added.cmp(&a.date_added)) -> sort_by_key
with Reverse.
The fork's CI had never been green. Pre-existing failures:
- version-sync: check-version-sync.js read packages/dashboard/package.json,
which doesn't exist in this fork (workspace is just "."). Drop the dashboard
comparison; check package.json vs cli/Cargo.toml only.
- Dashboard job: `pnpm install --filter dashboard` for a non-existent package.
Remove the job.
- Format check: repo was never `cargo fmt`-clean. Ran cargo fmt (mechanical).
- Clippy -D warnings (newly enforced on Rust 1.94 stable): manual_contains in
commands.rs (.iter().any()->.contains()), question_mark in element.rs
(if-let-Err -> ?), result_large_err on the tungstenite handshake callback in
connect.rs (allow — the Result type is fixed by the accept_hdr_async contract).
- rust-cross: lightpanda::waits_for_ready_without_logs spawns a real process +
binds a socket with timing assumptions; flaky in CI. Marked #[ignore].
Also: skill docs note fork.30's relay-preferred auto-connect (plain
`agent-browser open` is dialog-free once the ab-connect extension is loaded) and
the extension's new "agent-browser-stealth" display name.
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
rebrowser's navigatorWebdriver probe checks Object.getOwnPropertyNames(navigator)
== [] (real Chrome keeps navigator members on Navigator.prototype). The launch-mode
stealth script defined language/languages/userAgentData/contacts as instance
own-properties, leaking them as an automation tell.
- add __abRedefineNavProto(name, getterImpl): redefines a navigator member on the
PROTOTYPE with a native-masked getter toString, then deletes any instance shadow
(mirrors the existing vendor patch). Falls back to instance only if proto is locked.
- convert language/languages/userAgentData to it; make the contacts block prototype-first.
After: Object.getOwnPropertyNames(navigator) == [], values intact, getters native,
rebrowser navigatorWebdriver 🟢, runtimeEnableLeak/pwInitScripts 🟢, sannysoft 0 fails.
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
- stealth(plugins): stop overwriting real native navigator.plugins in headed
mode (the JS fake had a non-native item(), broken uint32 wrap → incolumitas
overflowTest FAIL, and an anachronistic Native Client plugin). Leave native
plugins untouched when present; modernize the headless-escape fallback to the
real 5 PDF-viewer set with masked-native item()/namedItem().
- connect: auto_connect_cdp() now prefers the dialog-free ab-connect relay over
the raw :9222 CDP port, so Chrome 136+'s "Allow remote debugging?" consent
modal no longer fires when the extension relay is live. Gated by a bare-TCP
relay_is_live() probe (+3 unit tests).
- extension: rename ab-connect to "agent-browser-stealth" + new stealth icon set
(16/32/48/128).
- docs(README): hero/shield/fingerprint images, expanded detector results
(CreepJS 0% stealth, incolumitas all-OK, BrowserScan CDP-clean), and a
"Verify it yourself" section. .gitignore: allow assets/ + extension icons.
Headless Chrome is a bot-detection tell: creepjs scores ~33% headless even with
--headless=new, while a headed window with a real GPU scores 0%. Since this is a
stealth fork, headless is now forbidden — build_chrome_args ignores the headless
LaunchOption and never emits --headless/--enable-unsafe-swiftshader/forced
--window-size. The only escape is AGENT_BROWSER_ALLOW_HEADLESS=1 for genuinely
display-less servers (discouraged — forfeits stealth).
Verified locally: default launch (no env) is headed (webdriver=false,
platform=MacIntel, no --headless flag); creepjs headed = 0% headless vs 33%
headless. chrome.rs: 48 tests pass incl. forbids-headless + escape.
platform_string() feeds the CDP Emulation.setUserAgentOverride 'platform' field,
which sets the LEGACY navigator.platform. It was returning the UA-CH form
("macOS"/"Linux") — but real Chrome reports navigator.platform = "MacIntel" on
macOS and "Linux x86_64" on Linux. "macOS" contradicts the UA's "Intel Mac OS X"
and is a trivial bot-detection tell (platform vs UA mismatch). UA-CH
(navigator.userAgentData.platform via platform_hint) stays "macOS"/"Windows"/
"Linux" — that form is correct there.
Verified locally on bot.sannysoft.com (all rows green incl. navigator.platform=
MacIntel) + eval probes: webdriver false, no Headless in UA, real WebGL
(Apple M3 Metal, not SwiftShader), plugins/permissions consistent.
ROOT CAUSE of per-session command drift on the extension path: the daemon's
liveness check (`is_connection_alive` → `Browser.getVersion`) is a BROWSER-level
command. The relay only answered Target.* locally and forwarded the rest, so
Browser.getVersion went to the extension, which can only do per-tab
chrome.debugger → it errored → CdpClient saw TransportError → connection deemed
DEAD → the daemon closed + reconnected + re-ran discover_and_attach_targets on
EVERY command. Each re-discover rebuilds pages from the relay's minimal
targetInfo and resets active_page_index=0, so eval/get-title/screenshot drifted
to the first tab (about:blank / a foreign focused tab).
Reproduced locally (throwaway Chrome + Extensions.loadUnpacked + fork.24 nm-host):
trace showed discover_and_attach_targets running on every command (pages
before=0) and [ev] active_idx reset to 0.
Fix: relay answers Browser.getVersion locally with a stub version (like
getTargets), so the liveness probe succeeds → connection stays alive → no
reconnect/re-discover → the session's active tab is preserved. Pairs with
fork.24's add_background_page. relay.rs: 10 unit tests.
After connect+grouping worked, follow-up eval/get-title/screenshot drifted to a
foreign tab: on a shared browser, Target.targetCreated events for tabs the user
or OTHER agent sessions open stream in and are drained on every command. The
drain path routed them through add_page(), which sets active_page_index to the
new page — so the session's active tab silently jumped to a foreign tab and its
commands landed there.
Add BrowserManager::add_background_page() (push without touching active, dedup by
target_id) and use it in the event-drain path. Explicit opens (tab new, the
add-and-switch paths) keep using add_page() and still focus the new tab.
Closes the last gap in concurrent multi-agent: each session now drives its OWN
tab regardless of other sessions'/the user's tab activity.
After the connect fix, extension connect reached the relay but Target.getTargets
failed: 'missing field title'. The ab-connect relay builds targets from the
extension's synthesized Target.attachedToTarget; the re-announce path
(reannounceAttachedTabs) emits a minimal targetInfo {targetId,type,attached}
with no title/url, so strict deserialize of TargetInfo blew up the whole
getTargets response.
Make TargetInfo.title/url #[serde(default)] (empty) — tolerant of minimal CDP
targetInfo from the relay (and the occasional real-CDP omission). Titles
re-populate from Target.targetInfoChanged / page events after attach.
`extension connect` rewrote argv to ["connect", <relay-url>] but the connect path
reads flags.cdp — parsed earlier from the original argv ("extension connect" →
None). So the relay URL was dropped and the daemon ran AUTO-CONNECT, grabbing
whatever Chrome it could discover: a stale remote-debugging Chrome on :9222
(indefinite hang), or triggering Chrome's "Allow remote debugging?" prompt on
machines without one. This is the EAGAIN/hang hermes hit on --session connect.
Fix: set flags.cdp = Some(relay_url) (+ disable auto_connect) in the
extension-connect branch so the daemon connects to the live relay endpoint.
Diagnosed via local repro (trace showed connect_cdp resolving ws://...:9222/
devtools/browser/... instead of the relay's ws://...:<port>/<guid>).
The nm-host fanned extension→client messages over a broadcast channel and
forwarded commands under the client's own id, so two sessions connected to one
relay collided: command replies went to every client and ids overlapped → the
2nd session's connect hung (EAGAIN after 30s×5) and responses cross-talked.
Now the relay demultiplexes:
- each forwarded command is re-keyed to a relay-global id mapped to (client,
original_id); the extension's reply routes back to ONLY that client with its
original id restored (relay.rs: pending map + ClientId)
- CDP events fan out to all clients (they ignore unknown sessions)
- nm-host keeps a client_id -> sender registry instead of a broadcast; clients
are unregistered + their pending dropped on disconnect
Unblocks concurrent multi-agent on one shared Chrome (each --session its own tab
group from fork.20). relay.rs: 9 unit tests incl. cross-client id isolation.