Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Attaching automation through Chrome’s remote debugging port (or “controlled

Browser Hand takes a different route: a small **Chrome extension** bridges your normal profile to a **local relay** on your machine. Agents talk to the CLI; the extension drives tabs. No remote-debug flag on the default profile, no WebDriver banner, and **background work by default** so automation doesn’t yank focus.

The extension is also **debugger-free by default**: it uses `chrome.scripting` and `chrome.tabs` rather than `chrome.debugger`, so there is no “started debugging this browser” infobar for sites to detect. That is why it also runs in Chrome-extension-compatible browsers that stub `chrome.debugger`, such as the macOS **Search** browser. Pick the CDP debugger transport in the popup (**Page control**) only if you want it; see [the skill](./skills/browser-hand/SKILL.md#page-control-debugger-free-by-default) for what each mode can and cannot do.

## What you get

- **Real sessions** — use the Chrome profile you already use (cookies, passkeys, extensions, open tabs).
Expand Down
36 changes: 26 additions & 10 deletions cli-js/src/tool.js
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,18 @@ class CdpClient {
}
}

/**
* True when Page.navigate already finished loading, so waiting for
* Page.loadEventFired would only burn the timeout: a relay-adopted blank tab, or
* the extension's debugger-free transport (reports loadComplete).
*/
export function navigationSettled(navResult) {
return (
navResult?.loadComplete === true ||
(typeof navResult?.loaderId === "string" && navResult.loaderId.startsWith("adopted-"))
);
}

async function attachTarget(cdp, targetId) {
const tryOnce = async () => {
const attach = await cdp.send("Target.attachToTarget", {
Expand Down Expand Up @@ -1302,13 +1314,15 @@ async function selectOrOpenCurrentTarget({ cdp, input, operation, targets, timeo
pageName: plan.pageName,
};
sessionId = await attachTarget(cdp, selected.targetId);
await cdp.send("Page.navigate", { url: cached.url }, sessionId);
await cdp
.waitForEvent("Page.loadEventFired", {
sessionId,
timeoutMs: 15000,
})
.catch(() => null);
const renav = await cdp.send("Page.navigate", { url: cached.url }, sessionId);
if (!navigationSettled(renav)) {
await cdp
.waitForEvent("Page.loadEventFired", {
sessionId,
timeoutMs: 15000,
})
.catch(() => null);
}
selected.url = cached.url;
setCachedNamedPage(plan.pageName, {
targetId: selected.targetId,
Expand Down Expand Up @@ -1391,6 +1405,10 @@ async function runCurrentDoctor(timeoutMs, input = {}) {
result.truncated = shaped.truncated;
}

// Which transport the extension drives pages with (debugger-free by default).
// Older extensions don't know the command; that is fine.
result.transport = await cdp.send("DevBrowser.getCapabilities").catch(() => null);

try {
const pageInfo = await openNamedRelayPage(smokeName, {
timeoutMs: Math.min(timeoutMs, 15000),
Expand Down Expand Up @@ -1796,9 +1814,7 @@ async function runCurrentOperation(input, timeoutMs) {
throw err;
}
const urlBeforeNav = selected.url;
const adopted =
typeof navResult?.loaderId === "string" && navResult.loaderId.startsWith("adopted-");
if (!adopted) {
if (!navigationSettled(navResult)) {
await cdp.waitForEvent("Page.loadEventFired", { sessionId, timeoutMs }).catch(() => null);
}
if (!navResult?.errorText) {
Expand Down
32 changes: 32 additions & 0 deletions cli-js/test/navigation-settled.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* The debugger-free transport (chrome.tabs.update) finishes loading before it
* replies, so the CLI must not wait for Page.loadEventFired afterwards: that
* event can already be gone, and the wait would burn the whole timeout on every
* goto/open.
*/

import assert from "node:assert/strict";
import test from "node:test";

import { navigationSettled } from "../src/tool.js";

test("navigationSettled: the scripting transport reports loadComplete", () => {
assert.equal(navigationSettled({ frameId: "tab-1", loaderId: "scripting-x", loadComplete: true }), true);
});

test("navigationSettled: a relay-adopted blank tab is already settled", () => {
assert.equal(navigationSettled({ loaderId: "adopted-abc" }), true);
});

test("navigationSettled: a CDP navigate still waits for Page.loadEventFired", () => {
assert.equal(navigationSettled({ frameId: "F1", loaderId: "L1" }), false);
});

test("navigationSettled: a scripting navigation that timed out is not settled", () => {
assert.equal(navigationSettled({ frameId: "tab-1", loaderId: "scripting-x" }), false);
});

test("navigationSettled: tolerates a missing result", () => {
assert.equal(navigationSettled(undefined), false);
assert.equal(navigationSettled(null), false);
});
30 changes: 26 additions & 4 deletions extension/__tests__/StateManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ describe("StateManager", () => {
describe("getState", () => {
it("should return default active + background focus when no stored state", async () => {
const state = await stateManager.getState();
expect(state).toEqual({ isActive: true, focusPolicy: "background" });
expect(state).toEqual({
isActive: true,
focusPolicy: "background",
transportMode: "scripting",
});
});

it("should return stored state when available and fill missing focusPolicy", async () => {
Expand All @@ -22,7 +26,11 @@ describe("StateManager", () => {
});

const state = await stateManager.getState();
expect(state).toEqual({ isActive: true, focusPolicy: "background" });
expect(state).toEqual({
isActive: true,
focusPolicy: "background",
transportMode: "scripting",
});
});

it("should preserve a stored window focus policy", async () => {
Expand All @@ -43,23 +51,37 @@ describe("StateManager", () => {
expect(stored.devBrowserActiveState).toEqual({
isActive: true,
focusPolicy: "background",
transportMode: "scripting",
});
});

it("defaults to the debugger-free transport and persists an explicit opt-in", async () => {
expect(await stateManager.getTransportMode()).toBe("scripting");
await stateManager.setState({ transportMode: "debugger" });
expect(await stateManager.getTransportMode()).toBe("debugger");
// Junk values never leave the safe default.
await stateManager.setState({ transportMode: "bogus" as never });
expect(await stateManager.getTransportMode()).toBe("scripting");
});

it("should update state from active to inactive", async () => {
await stateManager.setState({ isActive: true });
await stateManager.setState({ isActive: false });

const state = await stateManager.getState();
expect(state).toEqual({ isActive: false, focusPolicy: "background" });
expect(state).toEqual({
isActive: false,
focusPolicy: "background",
transportMode: "scripting",
});
});

it("should update focus policy without clearing isActive", async () => {
await stateManager.setState({ isActive: true });
await stateManager.setState({ focusPolicy: "tab" });

const state = await stateManager.getState();
expect(state).toEqual({ isActive: true, focusPolicy: "tab" });
expect(state).toEqual({ isActive: true, focusPolicy: "tab", transportMode: "scripting" });
});
});

Expand Down
Loading
Loading