Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
273e4df
feat: add compare-heapsnapshots command for offline heap snapshot com…
aeroxy Jul 3, 2026
612a303
fix: improve sorting of snapshot diffs by class name as a secondary c…
aeroxy Jul 4, 2026
9f5098e
refactor: improve readability and consistency
aeroxy Jul 5, 2026
e91d0ee
perf(memory): reduce allocations and peak memory during snapshot diff
aeroxy Jul 6, 2026
2b3f4fd
feat: improve Chrome devtools connection handling and safeguard daemo…
aeroxy Jul 7, 2026
15b1efb
fix(memory): validate snapshot integrity and standardize error handling
aeroxy Jul 7, 2026
ef2fcb6
refactor(memory): simplify class aggregate handling and update snapsh…
aeroxy Jul 7, 2026
3f178a6
refactor(memory): optimize class aggregate handling in build_class_ag…
aeroxy Jul 7, 2026
2e24379
refactor(memory): optimize snapshot diff processing by modifying argu…
aeroxy Jul 7, 2026
f1a4712
refactor(memory): streamline class aggregate handling and improve CSV…
aeroxy Jul 8, 2026
093bfa6
refactor(memory): optimize class aggregate insertion to reduce alloca…
aeroxy Jul 8, 2026
cfb3705
refactor(memory): extract node field offset resolution to improve cla…
aeroxy Jul 9, 2026
41f6c67
refactor(memory): enhance class aggregation by introducing type-based…
aeroxy Jul 9, 2026
5a52e58
feat: enhance heap snapshot diffing, validation, and offline tooling
aeroxy Jul 10, 2026
32dea10
perf: optimize heap snapshot diff detail computation and ID overlap d…
aeroxy Jul 10, 2026
9ae27ee
refactor: simplify error handling in heap snapshot capture
aeroxy Jul 10, 2026
0c9120b
refactor: improve kill daemon decision logic for interactive prompts
aeroxy Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ Detailed documentation for individual commands:

- [read-page](wiki/read-page.md) — page content extraction as markdown

## Agent Skill

`skill/chrome-devtools/SKILL.md` is the **source of truth** for the agent-facing
skill/documentation — it's what teaches an agent (e.g. Claude Code, opencode) how
to use this CLI (targeting, standard patterns, gotchas, failure handling, etc.).
It gets installed/copied into an agent's skills directory (e.g.
`~/.config/opencode/skills/chrome-devtools/SKILL.md`); that installed copy is a
**deployed artifact**, not the source — always edit `skill/chrome-devtools/SKILL.md`
in this repo, then re-sync/reinstall it, rather than editing the installed copy
directly. `skill/chrome-devtools/CUSTOM_SCRIPTING.md` documents `run-script` and
`adapter` in more depth.

## Key Concepts

### Daemon Architecture
Expand All @@ -54,6 +66,17 @@ A background daemon (`/tmp/chrome-devtools-daemon.sock`) keeps a persistent CDP
WebSocket connection. First CLI invocation spawns it; subsequent commands reuse
it. 5-minute idle timeout.

`CdpClient::connect` (`cdp.rs`) bounds the WebSocket handshake with a timeout
(`CHROME_CONNECT_TIMEOUT_SECS`, default 10s). Without it, a pending Chrome
remote-debugging consent dialog would hang the handshake indefinitely — and
since the daemon binds its socket *before* connecting to Chrome (see comments
in `daemon.rs`), the CLI's `wait_for_daemon()` would succeed immediately while
the actual request silently hung forever waiting on the daemon's response, with
no error and no way for a caller (especially an unattended agent) to tell what
was wrong. The timeout error message is written to be agent-actionable: retry
at most once, then stop and ask a human rather than looping or calling
`kill-daemon`.

### Page Targeting

Every page gets a deterministic friendly name (e.g. `warm-squid`) derived from
Expand All @@ -77,6 +100,12 @@ LLM-friendly) produce structured output. Mutually exclusive.
before any Chrome connection or daemon spawn. `inspect-heapsnapshot-node` parses
a local `.heapsnapshot` file purely offline.

`kill-daemon` drops the daemon's already-approved Chrome connection, so it's
guarded (`kill_daemon_decision` in `lib.rs`): interactive (TTY) callers get a
`[y/N]` confirmation prompt; non-interactive callers (agents, scripts) are
refused outright unless `--force` is passed. It must never be used as a
"retry" step for connection failures — see the timeout note above.

### Path Resolution

The daemon retains its startup CWD, so the CLI resolves all relative file-path
Expand Down
43 changes: 41 additions & 2 deletions skill/chrome-devtools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,33 @@ chrome-devtools --target <name> adapter <file-path> <function-name> [--arg key=v

### Daemon
```bash
chrome-devtools kill-daemon # stop the background daemon process
```
chrome-devtools kill-daemon # refuses when run non-interactively (agents) unless --force
chrome-devtools kill-daemon --force # kills unconditionally
```

## Failure Handling: "Failed to connect to Chrome" / a command hangs

Chrome's remote-debugging connection requires a one-time **human approval dialog**
in Chrome. If a command hangs or fails with a connection/timeout error, the most
likely cause is that this dialog is open and waiting for the human — not a bug
you can fix by retrying.

**If a command hangs for a long time or errors with "Failed to connect to Chrome"
or "Timed out ... connecting to Chrome":**
1. Retry **at most once** (the human may have already approved it just now).
2. If it fails again, **STOP.** Do not keep retrying — the human is very likely
away from the keyboard and no amount of retrying will approve the dialog for
them.
3. Tell the user directly that Chrome is waiting for them to approve the
remote-debugging connection dialog, and wait for their response.

**Never run `kill-daemon` as a way to "fix" a connection problem.** It does not
help — it destroys the daemon's already-approved connection (if one exists) and
guarantees the *next* attempt needs a fresh human approval, making things worse.
For this reason, `kill-daemon` refuses to run non-interactively without
`--force`. As an agent, only pass `--force` if the user has explicitly asked you
to kill the daemon for some other reason (e.g. it's stuck on unrelated JS
execution) — never as a reaction to a connection failure.

## Critical Gotchas

Expand Down Expand Up @@ -459,6 +484,20 @@ chrome-devtools --target warm-squid console
### ✓ CORRECT: Each drain is a fresh window
Run `console` / `network` right after the action that produces events, OR use `--duration` to collect for a window of time.

### ✗ WRONG: Retrying in a loop or running `kill-daemon` on connection failure
```bash
chrome-devtools list-pages # hangs / fails: "Failed to connect to Chrome"
chrome-devtools kill-daemon --force # ❌ WRONG: destroys the approved connection, makes it worse
chrome-devtools list-pages # retry again, and again... ❌ WRONG: human is likely away
```

### ✓ CORRECT: Retry once, then stop and ask the human
```bash
chrome-devtools list-pages # hangs / fails: "Failed to connect to Chrome"
chrome-devtools list-pages # ✓ one retry, in case the human just approved it
# still failing → STOP retrying, tell the user Chrome needs approval, and wait
```

## Output Format Summary

| Flag | Description |
Expand Down
28 changes: 27 additions & 1 deletion src/cdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,37 @@ fn stash_tab_emulation(
}
}

/// Read the Chrome WebSocket connect timeout from `CHROME_CONNECT_TIMEOUT_SECS`,
/// defaulting to 10.
fn connect_timeout() -> std::time::Duration {
std::env::var("CHROME_CONNECT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.unwrap_or(std::time::Duration::from_secs(10))
}

impl CdpClient {
/// Connect to Chrome via WebSocket and return a CDP client.
///
/// Bounded by a timeout: without it, a pending Chrome remote-debugging
/// consent dialog leaves the WebSocket handshake hanging indefinitely,
/// which (via the daemon) makes every command appear to hang forever
/// with no diagnostic.
pub async fn connect(ws_url: &str) -> Result<Self> {
let (ws, _) = connect_async(ws_url)
let timeout_dur = connect_timeout();
let (ws, _) = tokio::time::timeout(timeout_dur, connect_async(ws_url))
.await
.map_err(|_| {
anyhow!(
"Timed out after {}s connecting to Chrome at {ws_url}. Chrome may be \
waiting for a human to approve the remote-debugging connection dialog. \
If you are an automated agent: do not retry in a loop and do not run \
kill-daemon (it will not fix this and will require a fresh approval) — \
stop and ask the human to check Chrome, then retry once.",
timeout_dur.as_secs()
)
})?
.map_err(|e| anyhow!("Failed to connect to Chrome at {ws_url}: {e}"))?;
let (write, read) = ws.split();
Ok(Self {
Expand Down
98 changes: 72 additions & 26 deletions src/commands/evaluate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ pub async fn run_script(
}
}

let nav_url = if interpolated_url.starts_with("http://") || interpolated_url.starts_with("https://") {
let nav_url = if interpolated_url.starts_with("http://")
|| interpolated_url.starts_with("https://")
{
interpolated_url.clone()
} else if is_local_host(&interpolated_url) {
format!("http://{}", interpolated_url)
Expand All @@ -258,7 +260,10 @@ pub async fn run_script(

let current_url = client.current_url(session_id).await?;
if current_url.trim_end_matches('/') != nav_url.trim_end_matches('/') {
eprintln!("[script] Current URL '{}' does not match target URL '{}'. Auto-navigating...", current_url, nav_url);
eprintln!(
"[script] Current URL '{}' does not match target URL '{}'. Auto-navigating...",
current_url, nav_url
);

crate::commands::navigate::navigate(
client,
Expand Down Expand Up @@ -327,7 +332,10 @@ fn parse_adapter_domains(content: &str) -> Vec<String> {
if trimmed.starts_with("import ") || trimmed.starts_with("import(") {
continue;
}
if matches!(trimmed, "\"use strict\";" | "'use strict';" | "\"use strict\"" | "'use strict'") {
if matches!(
trimmed,
"\"use strict\";" | "'use strict';" | "\"use strict\"" | "'use strict'"
) {
continue;
}
break;
Expand Down Expand Up @@ -516,26 +524,29 @@ pub async fn run_adapter(
let domains = parse_adapter_domains(&script_content);
if !domains.is_empty() {
let current_url = client.current_url(session_id).await?;
let matched = domains.iter().any(|domain| url_matches_domain(&current_url, domain));
let matched = domains
.iter()
.any(|domain| url_matches_domain(&current_url, domain));

if !matched {
let target_domain = &domains[0];
// Preserve the host exactly as declared in `@domain`; only supply a
// scheme when one is missing. Forcing a `www.` subdomain breaks apex
// hosts and adapters that target an existing subdomain
// (e.g. `creator.xiaohongshu.com`).
let target_url = if target_domain.starts_with("http://") || target_domain.starts_with("https://") {
// An explicit scheme always wins, so authors can force http/https
// by writing it in `@domain` (e.g. `@domain http://localhost:3000`).
target_domain.clone()
} else if is_local_host(target_domain) {
// Local dev servers generally speak http, not https.
format!("http://{}", target_domain)
} else {
format!("https://{}", target_domain)
};
let target_url =
if target_domain.starts_with("http://") || target_domain.starts_with("https://") {
// An explicit scheme always wins, so authors can force http/https
// by writing it in `@domain` (e.g. `@domain http://localhost:3000`).
target_domain.clone()
} else if is_local_host(target_domain) {
// Local dev servers generally speak http, not https.
format!("http://{}", target_domain)
} else {
format!("https://{}", target_domain)
};
eprintln!("[adapter] Current URL '{}' does not match adapter domains {:?}. Auto-navigating to '{}'...", current_url, domains, target_url);

crate::commands::navigate::navigate(
client,
session_id,
Expand All @@ -549,7 +560,9 @@ pub async fn run_adapter(
.await?;

let post_nav_url = client.current_url(session_id).await?;
let post_matched = domains.iter().any(|domain| url_matches_domain(&post_nav_url, domain));
let post_matched = domains
.iter()
.any(|domain| url_matches_domain(&post_nav_url, domain));
if !post_matched {
anyhow::bail!(
"Auto-navigation to '{}' resulted in URL '{}' which does not match adapter domains {:?}",
Expand Down Expand Up @@ -720,7 +733,10 @@ mod tests {
// (only declarations and bare re-exports are handled).
let src = "export * from './x';\nexport const ok = 1;\nexport constants = 2;";
let out = strip_export_keywords(src);
assert_eq!(out, "export * from './x';\nconst ok = 1;\nexport constants = 2;");
assert_eq!(
out,
"export * from './x';\nconst ok = 1;\nexport constants = 2;"
);
}

#[test]
Expand Down Expand Up @@ -748,9 +764,15 @@ mod tests {

#[test]
fn test_normalize_host() {
assert_eq!(normalize_host("http://user:pass@example.com/some/path"), "example.com");
assert_eq!(
normalize_host("http://user:pass@example.com/some/path"),
"example.com"
);
assert_eq!(normalize_host("http://user:pass@[::1]:8080"), "[::1]");
assert_eq!(normalize_host("http://user:pass@127.0.0.1:8080"), "127.0.0.1");
assert_eq!(
normalize_host("http://user:pass@127.0.0.1:8080"),
"127.0.0.1"
);
assert_eq!(normalize_host("https://foo:bar@localhost"), "localhost");
assert_eq!(normalize_host("example.com"), "example.com");
assert_eq!(normalize_host("http://example.com:3000/"), "example.com");
Expand All @@ -772,19 +794,37 @@ mod tests {

#[test]
fn test_url_matches_domain() {
assert!(url_matches_domain("https://www.xiaohongshu.com/explore", "xiaohongshu.com"));
assert!(url_matches_domain("http://creator.xiaohongshu.com", "creator.xiaohongshu.com"));
assert!(url_matches_domain("https://xiaohongshu.com:8080/path", "xiaohongshu.com"));
assert!(url_matches_domain(
"https://www.xiaohongshu.com/explore",
"xiaohongshu.com"
));
assert!(url_matches_domain(
"http://creator.xiaohongshu.com",
"creator.xiaohongshu.com"
));
assert!(url_matches_domain(
"https://xiaohongshu.com:8080/path",
"xiaohongshu.com"
));
assert!(url_matches_domain("http://[::1]:3000", "[::1]"));
assert!(!url_matches_domain("https://google.com", "xiaohongshu.com"));
}

#[test]
fn test_url_matches_domain_normalizes_domain() {
// `@domain` written with a scheme and/or path still matches the host.
assert!(url_matches_domain("https://www.example.com/page", "https://example.com"));
assert!(url_matches_domain("https://example.com/explore", "example.com/path"));
assert!(url_matches_domain("https://example.com", "http://example.com:443/"));
assert!(url_matches_domain(
"https://www.example.com/page",
"https://example.com"
));
assert!(url_matches_domain(
"https://example.com/explore",
"example.com/path"
));
assert!(url_matches_domain(
"https://example.com",
"http://example.com:443/"
));
assert!(!url_matches_domain("https://example.com", ""));
}

Expand All @@ -800,7 +840,13 @@ mod tests {
let ctx = build_ctx_object(r#"{"query":"hi"}"#);
assert!(ctx.starts_with("const ctx = {"));
assert!(ctx.contains(r#"args: {"query":"hi"}"#));
for helper in ["wait:", "waitForText:", "waitForSelector:", "click:", "fill:"] {
for helper in [
"wait:",
"waitForText:",
"waitForSelector:",
"click:",
"fill:",
] {
assert!(ctx.contains(helper), "missing helper: {helper}");
}
// fill must special-case checkable inputs instead of setting `value`.
Expand Down
30 changes: 25 additions & 5 deletions src/commands/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub fn known_args(cmd: &str) -> &'static [&'static str] {
"read-page" => &["output"],
"take-heapsnapshot" => &["output"],
"inspect-heapsnapshot-node" => &["file_path", "node_id"],
"compare-heapsnapshots" => &["base", "current", "class_index"],
"emulate" => &[
"viewport",
"device_scale_factor",
Expand All @@ -72,9 +73,22 @@ pub fn known_args(cmd: &str) -> &'static [&'static str] {
"console" => &["duration", "type"],
"network" => &["duration", "type"],
"sw-logs" => &["duration", "extension_id"],
"run-script" => &["file_path", "script_args", "raw_args", "output", "track_navigation"],
"adapter" => &["file_path", "function_name", "script_args", "raw_args", "output", "track_navigation"],
"kill-daemon" => &[],
"run-script" => &[
"file_path",
"script_args",
"raw_args",
"output",
"track_navigation",
],
"adapter" => &[
"file_path",
"function_name",
"script_args",
"raw_args",
"output",
"track_navigation",
],
"kill-daemon" => &["force"],
_ => &[],
}
}
Expand Down Expand Up @@ -325,7 +339,10 @@ fn script_exec_args(args: &serde_json::Value) -> Result<ScriptExecArgs<'_>> {
.get("file_path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("file_path required"))?;
let script_args = args.get("script_args").cloned().unwrap_or_else(|| json!({}));
let script_args = args
.get("script_args")
.cloned()
.unwrap_or_else(|| json!({}));
let output = args.get("output").and_then(|v| v.as_str());
let track_navigation = args
.get("track_navigation")
Expand Down Expand Up @@ -412,7 +429,10 @@ async fn inner_execute(
client,
session_id,
commands::screenshot::ScreenshotOptions {
output: args.get("output").and_then(|v| v.as_str()).map(String::from),
output: args
.get("output")
.and_then(|v| v.as_str())
.map(String::from),
format: args
.get("format")
.and_then(|v| v.as_str())
Expand Down
Loading