Compare commits

...
Author SHA1 Message Date
leeguooooo 28d3748c06 chore(release): 0.27.0-fork.44 — eval origin stamp, type --focused, humanize bogus warn
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
2026-06-11 22:48:32 +09:00
leeguooooo fa47a0b8e5 feat: eval prints its origin URL, type --focused, AGENT_BROWSER_HUMANIZE bogus warn
- 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).
2026-06-11 22:48:31 +09:00
leeguooooo 36c593631c chore(release): 0.27.0-fork.43 — issue/Hermes batch 1 (silent-click, live env, eval --file, tab-list, docs)
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
2026-06-11 22:35:35 +09:00
leeguooooo b92757412d fix(click): occlusion guard for selector clicks — no more silent false success
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.
2026-06-11 22:34:12 +09:00
leeguooooo 6ecda4d706 fix: per-invocation env (CLICK_MODE / HUMANIZE) reaches a running daemon
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.
2026-06-11 22:29:39 +09:00
leeguooooo 123510db2b feat: eval --file, tab-list URL truncation, skill doc fixes (issue #2/#3 + Hermes)
- `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.
2026-06-11 22:22:07 +09:00
leeguooooo abb65c632b chore(release): 0.27.0-fork.42 — close session-owned tabs on exit (no tab leak)
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
2026-06-11 21:54:44 +09:00
leeguooooo e803bffbbb fix(tabs): close the session's own tabs on exit (stop leaking into the user's Chrome)
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).
2026-06-11 21:54:42 +09:00
leeguooooo 96ee2f9758 chore(release): 0.27.0-fork.41 — relay self-heals silently (no user action on blip)
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
2026-06-11 21:30:38 +09:00
leeguooooo 0a3d2a91a6 fix(connect): self-heal the relay silently — ~15s retry, no user action
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.
2026-06-11 21:30:37 +09:00
11 changed files with 243 additions and 22 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.40"
version = "0.27.0-fork.44"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.40"
version = "0.27.0-fork.44"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+30 -7
View File
@@ -403,9 +403,18 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") }))
}
"type" => {
// `type --focused <text>` types into whatever element currently has
// focus (no selector) — for custom widgets that move focus to a hidden
// input after you open them.
if rest.first() == Some(&"--focused") {
return Ok(json!({
"id": id, "action": "type", "focused": true,
"text": rest[1..].join(" "),
}));
}
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "type".to_string(),
usage: "type <selector> <text>",
usage: "type <selector> <text> (or: type --focused <text>)",
})?;
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") }))
}
@@ -841,17 +850,31 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// === Eval ===
"eval" => {
// Check for flags: -b/--base64 or --stdin
let (is_base64, is_stdin, script_parts): (bool, bool, &[&str]) =
// Check for flags: -b/--base64, --stdin, or --file <path>
let (is_base64, is_stdin, is_file, script_parts): (bool, bool, bool, &[&str]) =
if rest.first() == Some(&"-b") || rest.first() == Some(&"--base64") {
(true, false, &rest[1..])
(true, false, false, &rest[1..])
} else if rest.first() == Some(&"--stdin") {
(false, true, &rest[1..])
(false, true, false, &rest[1..])
} else if rest.first() == Some(&"--file") {
(false, false, true, &rest[1..])
} else {
(false, false, rest.as_slice())
(false, false, false, rest.as_slice())
};
let script = if is_stdin {
let script = if is_file {
// Read the script from a file. Avoids shell-mangling of inline JS
// (non-ASCII identifiers/strings, quotes, large scripts) — the file
// is read as UTF-8 and sent verbatim.
let path = script_parts.first().ok_or(ParseError::InvalidValue {
message: "eval --file requires a path".to_string(),
usage: "eval --file <path>",
})?;
std::fs::read_to_string(path).map_err(|e| ParseError::InvalidValue {
message: format!("eval --file: cannot read {path}: {e}"),
usage: "eval --file <path>",
})?
} else if is_stdin {
// Read script from stdin
let stdin = io::stdin();
let lines: Vec<String> = stdin
+27 -1
View File
@@ -821,7 +821,33 @@ fn connect(session: &str) -> Result<Connection, String> {
}
}
pub fn send_command(cmd: Value, session: &str) -> Result<Response, String> {
pub fn send_command(mut cmd: Value, session: &str) -> Result<Response, String> {
// Forward per-invocation env to the daemon. The daemon's environment is
// frozen at spawn, so settings like AGENT_BROWSER_CLICK_MODE /
// AGENT_BROWSER_HUMANIZE (incl. the --humanize flag, which sets the latter)
// are otherwise silently ignored on an already-running daemon. Carry them in
// the envelope so they apply to THIS command.
if let Some(obj) = cmd.as_object_mut() {
if let Ok(m) = std::env::var("AGENT_BROWSER_CLICK_MODE") {
obj.insert("_clickMode".to_string(), Value::String(m));
}
if let Ok(h) = std::env::var("AGENT_BROWSER_HUMANIZE") {
// Only forward a recognized level; warn once (like the --humanize flag
// does) when the env var is set to garbage, instead of silently
// ignoring it.
if crate::native::humanize::HumanizeLevel::parse(&h).is_some() {
obj.insert("_humanize".to_string(), Value::String(h));
} else {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
eprintln!(
"warning: AGENT_BROWSER_HUMANIZE must be off|fast|human, got {h:?} (ignored)"
);
});
}
}
}
// Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues)
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 200;
+33
View File
@@ -1160,6 +1160,23 @@ impl Drop for DaemonState {
pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or("");
// Apply per-invocation overrides the client forwarded (the daemon's own env
// is frozen at spawn). CLICK_MODE is read fresh from the process env by
// interaction::click, so mirror it here — set when this command provided it,
// clear otherwise, so a value from an earlier command never leaks forward.
match cmd.get("_clickMode").and_then(|v| v.as_str()) {
Some(m) if !m.is_empty() => std::env::set_var("AGENT_BROWSER_CLICK_MODE", m),
_ => std::env::remove_var("AGENT_BROWSER_CLICK_MODE"),
}
// Humanize: set the session level from the client's --humanize / env. Only
// set when provided (don't clear — the adaptive per-navigation detector also
// owns this level between explicit overrides).
if let Some(h) = cmd.get("_humanize").and_then(|v| v.as_str()) {
if let Some(level) = super::humanize::HumanizeLevel::parse(h) {
super::humanize::set_detected_level(level);
}
}
let id = cmd
.get("id")
.and_then(|v| v.as_str())
@@ -3014,6 +3031,22 @@ async fn handle_fill(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
// `type --focused <text>`: type into the currently-focused element without a
// selector (custom widgets that move focus to a hidden input on open).
if cmd
.get("focused")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let text = cmd
.get("text")
.and_then(|v| v.as_str())
.ok_or("Missing 'text' parameter")?;
interaction::type_text_into_active_context(&mgr.client, &session_id, text, None).await?;
return Ok(json!({ "typed": text, "focused": true }));
}
let selector = cmd
.get("selector")
.and_then(|v| v.as_str())
+33
View File
@@ -309,6 +309,12 @@ pub struct BrowserManager {
pub ignore_https_errors: bool,
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
visited_origins: HashSet<String>,
/// Target IDs of tabs THIS session created via `Target.createTarget`. When
/// connected to the user's real Chrome (not a launched browser), these are
/// closed on `close()` so the session's tabs don't pile up in the user's
/// browser after it ends. Only ever holds tabs we created — never the user's
/// existing tabs or other sessions' tabs — so closing them is always safe.
created_targets: HashSet<String>,
next_tab_id: u32,
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
@@ -433,6 +439,7 @@ impl BrowserManager {
download_path: download_path.clone(),
ignore_https_errors,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -523,6 +530,7 @@ impl BrowserManager {
download_path: None,
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -586,6 +594,8 @@ impl BrowserManager {
None,
)
.await?;
// We created this tab — own it so close() can clean it up.
self.created_targets.insert(result.target_id.clone());
let attach_result: AttachToTargetResult = self
.client
@@ -892,6 +902,24 @@ impl BrowserManager {
.client
.send_command_no_params("Browser.close", None)
.await;
} else {
// Connected to the user's real Chrome: we must NOT close their
// browser, but we DO own the tabs this session created. Close them so
// they don't pile up in the user's window (in their per-session tab
// group) every time a session ends, idles out, or the daemon shuts
// down. `created_targets` only holds tabs we made via
// Target.createTarget — never the user's existing tabs or other
// sessions' — so this is always safe. Best-effort per tab.
for target_id in self.created_targets.drain() {
let _ = self
.client
.send_command_typed::<_, Value>(
"Target.closeTarget",
&CloseTargetParams { target_id },
None,
)
.await;
}
}
if let Some(mut process) = self.browser_process.take() {
@@ -993,6 +1021,8 @@ impl BrowserManager {
None,
)
.await?;
// We created this tab — own it so close() can clean it up.
self.created_targets.insert(result.target_id.clone());
let attach_result: AttachToTargetResult = self
.client
@@ -1158,6 +1188,8 @@ impl BrowserManager {
None,
)
.await?;
// We created this tab — own it so close() can clean it up.
self.created_targets.insert(result.target_id.clone());
let attach: AttachToTargetResult = self
.client
@@ -1750,6 +1782,7 @@ async fn initialize_lightpanda_manager(
download_path: None,
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
+7 -1
View File
@@ -790,8 +790,14 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
// the extension is set up we must NEVER fall through to the raw :9222 path
// below: that pops Chrome 136+'s "Allow remote debugging?" dialog, the exact
// thing the extension exists to avoid.
// ~15s of retries (500ms apart) when the extension is installed: long enough
// for the MV3 service worker to wake and reconnect on its own (onStartup
// after a Chrome restart, or the keepalive alarm) so the relay self-heals
// with NO user action. The loop re-checks the relay file every iteration, so
// a recovery mid-wait is picked up immediately — the full window is only ever
// spent when the extension is genuinely down.
let host_installed = crate::connect::host_installed();
let relay_attempts = if host_installed { 10 } else { 1 };
let relay_attempts = if host_installed { 30 } else { 1 };
for attempt in 0..relay_attempts {
if let Some(relay) = crate::connect::relay_url() {
// The relay is a local CDP-over-WS endpoint we connect to like Chrome.
+64 -1
View File
@@ -4,7 +4,7 @@ use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
use super::element::{parse_ref, resolve_element_center, resolve_element_object_id, RefMap};
use super::humanize;
pub async fn click(
@@ -56,6 +56,33 @@ pub async fn click(
match resolved {
Ok((cx, cy, w, h, effective_session_id)) => {
// Occlusion guard for the CSS-selector path. `@ref` clicks are already
// occlusion-checked in resolve_element_center, but a plain selector
// resolves to coordinates without that check — so an overlay (modal
// backdrop, sticky banner, the getByText located node sitting under a
// full-screen layer) would make the coordinate click land on the
// overlay and still report success. If the click point doesn't hit the
// target, dispatch through the DOM instead (targets the element
// directly). Skipped for strict `coord` mode and non-left/multi-clicks.
if mode != "coord"
&& button == "left"
&& click_count == 1
&& parse_ref(selector_or_ref).is_none()
&& point_misses_element(client, &effective_session_id, selector_or_ref).await
{
eprintln!(
"[click] target occluded at its click point; dispatching through \
the DOM (set AGENT_BROWSER_CLICK_MODE=coord to disable)"
);
return dom_click(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await;
}
// Land on a jittered point inside the element rather than its exact
// centre (Fast/Human). Zero size or Off → exact centre.
let (tx, ty) = humanize::landing_point(
@@ -92,6 +119,42 @@ pub async fn click(
}
}
/// True if a coordinate click at the selector's centre would land on something
/// OTHER than the element (an overlay on top), i.e. the element is occluded.
/// `false` when not occluded, the element is missing, or the probe fails (so we
/// never block a click on a flaky probe — the normal coordinate path runs).
async fn point_misses_element(client: &CdpClient, session_id: &str, selector: &str) -> bool {
let js = format!(
r#"(() => {{
const el = document.querySelector({sel});
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (!hit) return false;
// Not occluded if the hit is the element, a descendant, or an ancestor
// wrapper (clicking those still reaches the element's handlers).
return !(hit === el || el.contains(hit) || hit.contains(el));
}})()"#,
sel = serde_json::to_string(selector).unwrap_or_default()
);
match client
.send_command_typed::<_, EvaluateResult>(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await
{
Ok(r) => r.result.value.and_then(|v| v.as_bool()).unwrap_or(false),
Err(_) => false,
}
}
/// Best-effort scroll-into-view before a coordinate click. Uses Chrome's
/// `scrollIntoViewIfNeeded` (only scrolls when not already fully visible),
/// falling back to centered `scrollIntoView`. Resolution failures are ignored —
+27
View File
@@ -130,6 +130,20 @@ fn format_stream_status_text(action: Option<&str>, data: &serde_json::Value) ->
}
}
/// Shorten an over-long string by keeping its head and tail and eliding the
/// middle, with a char count. Used so multi-KB URLs (JWT/OTP login links) don't
/// flood `tab list`.
fn truncate_middle(s: &str, max: usize) -> String {
let n = s.chars().count();
if n <= max {
return s.to_string();
}
let keep = max.saturating_sub(1) / 2;
let head: String = s.chars().take(keep).collect();
let tail: String = s.chars().skip(n - keep).collect();
format!("{head}{tail} [{n} chars]")
}
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
if opts.json {
if opts.content_boundaries {
@@ -344,6 +358,16 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
}
// Eval result
if let Some(result) = data.get("result") {
// Surface which page the eval actually ran on — to stderr, so it
// never corrupts the parsed value on stdout. Lets an agent catch tab
// drift (commands landing on the wrong tab) before trusting a result,
// e.g. a logged-in `fetch` that hit the wrong origin. (In
// content-boundaries mode the origin is already in the banner.)
if !opts.content_boundaries {
if let Some(o) = origin.filter(|o| !o.is_empty()) {
eprintln!("eval @ {o}");
}
}
let formatted = serde_json::to_string_pretty(result).unwrap_or_default();
print_with_boundaries(&formatted, origin, opts);
return;
@@ -422,6 +446,9 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
.and_then(|v| v.as_str())
.unwrap_or("Untitled");
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
// the list stays readable instead of flooding the terminal.
let url = truncate_middle(url, 120);
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
let marker = if active {
color::cyan("")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.40",
"version": "0.27.0-fork.44",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",
+19 -9
View File
@@ -182,14 +182,17 @@ Snapshot output looks like:
Page: Example - Log in
URL: https://example.com/login
@e1 [heading] "Log in"
@e2 [form]
@e3 [input type="email"] placeholder="Email"
@e4 [input type="password"] placeholder="Password"
@e5 [button type="submit"] "Continue"
@e6 [link] "Forgot password?"
- heading "Log in" [level=1, ref=e1]
- textbox "Email" [ref=e2]
- textbox "Password" [ref=e3]
- button "Continue" [ref=e4]
- link "Forgot password?" [ref=e5]
```
Each line is `- <role> "<accessible name>" [<attrs>, ref=eN]`, indented by nesting
depth. You pass the ref to commands as `@eN` (e.g. `click @e4`). Refs are
assigned fresh on every snapshot.
For unstructured reading (no refs needed):
```bash
@@ -386,9 +389,16 @@ Array.from(rows).map(r => ({
EOF
```
Prefer `eval --stdin` (heredoc) or `eval -b <base64>` for any JS with
quotes or special characters. Inline `agent-browser eval "..."` works
only for simple expressions.
Prefer `eval --stdin` (heredoc), `eval --file <path>`, or `eval -b <base64>`
for any JS with quotes, **non-ASCII identifiers/strings (e.g. Chinese)**, or
large scripts — inline `agent-browser eval "..."` is shell-mangled and works
only for simple ASCII expressions.
**`eval` runs in the page's MAIN world and state persists across calls**, so a
top-level `const x`/`let x`/`var x` in one call collides with the next
(`SyntaxError: Identifier 'x' has already been declared`). Either use unique
names, assign to `window.x`, or wrap the body in an IIFE
(`(() => { const x = …; return x; })()`).
### Screenshot