Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
40 changes: 36 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ src/
├── screenshot.rs
├── snapshot.rs
├── read_page.rs # read-page (Readability + HTML→Markdown)
├── memory.rs # take-heapsnapshot (CDP streaming) + inspect-heapsnapshot-node (offline)
├── memory.rs # take-heapsnapshot (CDP streaming) + inspect-heapsnapshot-node / compare-heapsnapshots (offline)
├── evaluate.rs
├── input.rs # click/fill/type/press/hover
├── emulation.rs # emulate (viewport/geolocation/blocklist)
Expand All @@ -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 @@ -73,9 +96,18 @@ LLM-friendly) produce structured output. Mutually exclusive.

### Offline Commands

`inspect-heapsnapshot-node` and `kill-daemon` are intercepted early in `run()`
before any Chrome connection or daemon spawn. `inspect-heapsnapshot-node` parses
a local `.heapsnapshot` file purely offline.
`inspect-heapsnapshot-node`, `compare-heapsnapshots`, and `kill-daemon` are
intercepted early in `run()` before any Chrome connection or daemon spawn.
`inspect-heapsnapshot-node` and `compare-heapsnapshots` parse local
`.heapsnapshot` files purely offline. Note that snapshot diffing matches nodes
by V8 heap object ID, which is only stable within a single Chrome session —
both snapshots must come from the same session to produce a meaningful diff.

`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

Expand Down
82 changes: 81 additions & 1 deletion skill/chrome-devtools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,40 @@ See the dedicated [Custom Scripting Guide](./CUSTOM_SCRIPTING.md) for full docum
chrome-devtools --target warm-squid adapter skill/chrome-devtools/examples/hn_adapter.js search -- "Rust"
```

### Pattern 15: Memory Leak Debugging (Heap Snapshots)

Take two heap snapshots around a suspected leak, diff them per class, then
drill into individual node IDs. `compare-heapsnapshots` and
`inspect-heapsnapshot-node` are fully offline (they parse local files — no
Chrome connection needed).

```bash
# 1. Take a baseline snapshot
chrome-devtools --target warm-squid take-heapsnapshot --output /tmp/base.heapsnapshot

# 2. Perform the leaky action in the page (click, navigate, etc.), then take a second snapshot
chrome-devtools --target warm-squid take-heapsnapshot --output /tmp/current.heapsnapshot

# 3. Diff: one row per class with added/removed counts and sizes, sorted by size delta
chrome-devtools compare-heapsnapshots --base /tmp/base.heapsnapshot --current /tmp/current.heapsnapshot
# idx,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta
# 0,Detached HTMLDivElement,120,0,120,46080,0,46080
# ...

# 4. Detail for one summary row (use the idx column): per-node-id adds/removes
chrome-devtools compare-heapsnapshots --base /tmp/base.heapsnapshot --current /tmp/current.heapsnapshot --class-index 0

# 5. Inspect a specific node ID from the detail output
chrome-devtools inspect-heapsnapshot-node --file-path /tmp/current.heapsnapshot --node-id 12345
```

**⚠️ Both snapshots must come from the same Chrome session.** The diff matches
nodes by V8 heap object ID, which is only stable within a single browser
session. Comparing snapshots taken across a Chrome restart (or from different
profiles/machines) produces a meaningless result where nearly everything is
reported as both added and removed — the CLI prints a warning on stderr when
it detects this.

## Complete Command Reference

### Navigation
Expand All @@ -359,6 +393,13 @@ chrome-devtools --target <name> console [--duration <ms>] [--type <level>]
chrome-devtools sw-logs [--duration <ms>] [--extension-id <id>]
```

### Memory (heap snapshots)
```bash
chrome-devtools --target <name> take-heapsnapshot --output <path.heapsnapshot>
chrome-devtools compare-heapsnapshots --base <path> --current <path> [--class-index N] # offline
chrome-devtools inspect-heapsnapshot-node --file-path <path> --node-id <id> # offline
```

### Interaction
```bash
chrome-devtools --target <name> click "<css-selector>"
Expand Down Expand Up @@ -396,9 +437,34 @@ 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

### ✗ WRONG: Using invented target names
Expand Down Expand Up @@ -459,6 +525,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
89 changes: 88 additions & 1 deletion src/cdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,56 @@ fn stash_tab_emulation(
}
}

/// Read the Chrome WebSocket connect timeout from `CHROME_CONNECT_TIMEOUT_SECS`,
/// defaulting to 10. Kept pure (value passed in) so the parsing policy is
/// unit-testable without mutating process-global env vars.
fn parse_connect_timeout(raw: Option<&str>) -> std::time::Duration {
const DEFAULT: std::time::Duration = std::time::Duration::from_secs(10);
match raw {
None => DEFAULT,
Some(v) => match v.parse::<u64>() {
// 0 would make every connect fail instantly with the misleading
// "Chrome may be waiting for approval" message; treat it as
// invalid rather than honoring it.
Ok(secs) if secs > 0 => std::time::Duration::from_secs(secs),
_ => {
eprintln!(
"[warning] Ignoring invalid CHROME_CONNECT_TIMEOUT_SECS value '{v}' \
(expected a positive integer); using default of {}s",
DEFAULT.as_secs()
);
DEFAULT
}
},
}
}

fn connect_timeout() -> std::time::Duration {
let raw = std::env::var("CHROME_CONNECT_TIMEOUT_SECS").ok();
parse_connect_timeout(raw.as_deref())
}

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 Expand Up @@ -884,6 +929,48 @@ mod tests {
use super::*;
use serde_json::json;

#[test]
fn test_parse_connect_timeout_default_when_unset() {
assert_eq!(
parse_connect_timeout(None),
std::time::Duration::from_secs(10)
);
}

#[test]
fn test_parse_connect_timeout_valid_value() {
assert_eq!(
parse_connect_timeout(Some("3")),
std::time::Duration::from_secs(3)
);
}

#[test]
fn test_parse_connect_timeout_rejects_zero() {
// 0 would make every connect fail instantly with a misleading
// "waiting for approval" error — must fall back to the default.
assert_eq!(
parse_connect_timeout(Some("0")),
std::time::Duration::from_secs(10)
);
}

#[test]
fn test_parse_connect_timeout_rejects_garbage() {
assert_eq!(
parse_connect_timeout(Some("fast")),
std::time::Duration::from_secs(10)
);
assert_eq!(
parse_connect_timeout(Some("-5")),
std::time::Duration::from_secs(10)
);
assert_eq!(
parse_connect_timeout(Some("")),
std::time::Duration::from_secs(10)
);
}

#[test]
fn test_event_buffer_capping() {
let mut events = std::collections::VecDeque::new();
Expand Down
Loading