Skip to content
Merged
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
3 changes: 2 additions & 1 deletion extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ Fast mode (Jev) is on by default.
- Pages often navigate a moment after an action (a search that submits once its suggestions load). WebPilot notices when the page starts changing after it last looked, and looks again instead of clicking on the page being left.
- Answers stream into the panel as they are written, including the planner's (it replies in plain text rather than through a tool call, whose arguments only arrive at the end); Jev then checks them against every page it saw (are the facts on those pages, and is every part of the request answered?), and an answer that fails is redone (first by Jev looking closer, then by the model).
- Requests that only find or read information never send, buy or delete anything: those controls aren't even considered.
- When a request does ask for something irreversible (buy, place order, checkout, send, delete, publish, unsubscribe...), the panel shows **"Hold on — this can't be undone"** before each such click, with Allow and Skip. Skip leaves that action undone (it isn't asked about again during the task) and WebPilot carries on with the rest, saying in the answer what it skipped. Skipping a sign-in stops work on that site at once (nothing behind it can be reached): WebPilot finishes any other part of the request and tells you to sign in and ask again. The stop button ends the whole task.
- When a request does ask for something irreversible (buy, place order, checkout, send, delete, publish, unsubscribe...), the panel shows **"Hold on — this can't be undone"** before each such click, with Allow and Skip. Skip leaves that action undone (it isn't asked about again during the task) and WebPilot carries on with the rest, saying in the answer what it skipped. After a skip, WebPilot judges whether anything can still move forward without it (other parts of the request, other sources for the same information) or whether the rest depends on it.
- Anything only you can clear (signing in, a CAPTCHA, a paywall, a verification code, payment details, a consent prompt, or something you skipped) is handled one way: WebPilot never tries to get past it or fake it, does everything else it can first, then tells you exactly what's needed to finish (for example, sign in to that site in this tab and ask again). The stop button ends the whole task.
- Sites are picked for your country, from your browser's language and time zone (walmart.ca rather than walmart.com in Canada).
- WebPilot never types into password, one-time-code or card fields and never makes up usernames or other personal details. On a sign-in page your password manager has already filled in, clicking "Log in" asks first (**"Hold on — sign in?"**); otherwise it asks you to sign in yourself and then continue.
- A follow-up like "no, it's on Gmail" or "try again" continues a request that didn't finish.
Expand Down
29 changes: 24 additions & 5 deletions extension/src/dom-browser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="chrome" />

import { STALE_PAGE_MESSAGE } from "../../lib/cdp/driver";
import { AFTER_DOM_READY_MS, STALE_PAGE_MESSAGE } from "../../lib/cdp/driver";
import { axTreeToSnapshot } from "../../lib/cdp/snapshot";
import type { TaskBrowser } from "../../lib/core/run-task";
import { clickElement, pressKeyInPage, readPageText, snapshotPage, typeIntoElement, waitForQuiet } from "./page-scripts";
Expand All @@ -19,6 +19,27 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Resolves once the tab's page is usable: fully loaded, or its DOM has been
* ready for a moment (heavy sites keep loading ads and trackers long after).
*/
export async function waitForPageReady(tabId: number, maxMs = LOAD_TIMEOUT_MS): Promise<void> {
const deadline = Date.now() + maxMs;
let domReadyAt = 0;
while (Date.now() < deadline) {
const tab = await chrome.tabs.get(tabId).catch(() => null);
if (!tab || tab.status === "complete") return;
if (domReadyAt) {
if (Date.now() - domReadyAt > AFTER_DOM_READY_MS) return;
} else if (!tab.pendingUrl) {
// Only once the new document has committed, or this would read the old one.
const results = await chrome.scripting.executeScript({ target: { tabId }, func: () => document.readyState }).catch(() => []);
if (results?.[0]?.result && results[0].result !== "loading") domReadyAt = Date.now();
}
await sleep(150);
}
}

function refId(ref: string): number {
const match = ref.match(/^d(\d+)$/);
if (!match) throw new Error(`Unknown element ref "${ref}". Take a new snapshot.`);
Expand Down Expand Up @@ -47,8 +68,7 @@ export class DomBrowser implements TaskBrowser {
private async ensureSnapshotCurrent() {
const tab = await chrome.tabs.get(this.tabId);
if (tab.status !== "loading" && (!this.snapshotUrl || tab.url === this.snapshotUrl)) return;
const deadline = Date.now() + LOAD_TIMEOUT_MS;
while (Date.now() < deadline && (await chrome.tabs.get(this.tabId)).status === "loading") await sleep(50);
await waitForPageReady(this.tabId);
await sleep(SETTLE_MS);
throw new Error(STALE_PAGE_MESSAGE);
}
Expand Down Expand Up @@ -86,8 +106,7 @@ export class DomBrowser implements TaskBrowser {
const windowEnd = Date.now() + NAVIGATION_START_WINDOW_MS;
while (!loading && Date.now() < windowEnd) await sleep(30);
if (loading) {
const deadline = Date.now() + LOAD_TIMEOUT_MS;
while (!complete && Date.now() < deadline) await sleep(50);
if (!complete) await waitForPageReady(this.tabId);
await sleep(SETTLE_MS);
}
} finally {
Expand Down
25 changes: 4 additions & 21 deletions extension/src/tab-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
import { CdpBrowser } from "../../lib/cdp/driver";
import type { TaskBrowser } from "../../lib/core/run-task";
import { DebuggerTransport, isDebuggerBlocked, isRestrictedUrl } from "./debugger-transport";
import { DomBrowser } from "./dom-browser";
import { DomBrowser, waitForPageReady } from "./dom-browser";

const TAB_LOAD_TIMEOUT_MS = 15_000;
/** A site that hasn't started responding by then is treated as down. */
const COMMIT_TIMEOUT_MS = 8_000;
const RESTRICTED_MESSAGE = "This tab shows a Chrome page that extensions can't control. Mention a website to open, or switch to a regular web page.";
Expand All @@ -15,22 +14,6 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/** Resolves once the tab finishes loading (or after a timeout). */
function waitForTabLoad(tabId: number): Promise<void> {
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
chrome.tabs.onUpdated.removeListener(listener);
resolve();
};
const listener = (updatedId: number, change: { status?: string }) => {
if (updatedId === tabId && change.status === "complete") done();
};
const timer = setTimeout(done, TAB_LOAD_TIMEOUT_MS);
chrome.tabs.onUpdated.addListener(listener);
});
}

/**
* Waits for a navigation away from a Chrome page to commit (the site starts
* responding), then for it to load. Returns false if the site never responds.
Expand All @@ -41,7 +24,7 @@ async function waitForSite(tabId: number): Promise<boolean> {
const tab = await chrome.tabs.get(tabId).catch(() => null);
if (!tab) return false;
if (!isRestrictedUrl(tab.url)) {
if (tab.status === "loading") await waitForTabLoad(tabId);
if (tab.status === "loading") await waitForPageReady(tabId);
return true;
}
await sleep(100);
Expand Down Expand Up @@ -98,7 +81,7 @@ export class TabBrowser implements TaskBrowser {
await this.dropTransport();
const tab = await this.tab();
if (isRestrictedUrl(tab.url)) throw new Error(RESTRICTED_MESSAGE);
if (tab.status === "loading") await waitForTabLoad(this.tabId);
if (tab.status === "loading") await waitForPageReady(this.tabId);
try {
this.transport = await DebuggerTransport.attach(this.tabId);
this.cdp = new CdpBrowser(this.transport);
Expand Down Expand Up @@ -137,7 +120,7 @@ export class TabBrowser implements TaskBrowser {
transport.detachedReason = lostControl;
if (changesPage && transport.actionsSent > actionsBefore) {
const tab = await this.tab();
if (tab.status === "loading") await waitForTabLoad(this.tabId);
if (tab.status === "loading") await waitForPageReady(this.tabId);
return fallback;
}
return operation(await this.driver());
Expand Down
13 changes: 10 additions & 3 deletions lib/cdp/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface CdpTransport {
const NAVIGATION_START_WINDOW_MS = 400;
const LOAD_TIMEOUT_MS = 12_000;
const SETTLE_MS = 250;
/** How long past DOMContentLoaded to wait for the load event. */
export const AFTER_DOM_READY_MS = 2500;
/** How long a navigation request may take to start loading before it's treated as same-document. */
const NAVIGATION_REQUEST_GRACE_MS = 1500;
const NAVIGATE_RESPONSE_TIMEOUT_MS = 10_000;
Expand Down Expand Up @@ -128,10 +130,13 @@ export class CdpBrowser implements FastModeBrowser {
await this.enable();
let navigating = false;
let loaded = false;
let domReadyAt = 0;
const unsubscribe = this.transport.onEvent((method, params) => {
const frame = (params.frame || {}) as { parentId?: string };
if (method === "Page.frameStartedLoading" || (method === "Page.frameNavigated" && !frame.parentId)) navigating = true;
if (method === "Page.loadEventFired" || method === "Page.frameStoppedLoading") loaded = true;
const main = params.frameId === undefined || params.frameId === this.mainFrameId;
if ((method === "Page.frameStartedLoading" && main) || (method === "Page.frameNavigated" && !frame.parentId)) navigating = true;
if (method === "Page.domContentEventFired") domReadyAt = Date.now();
if (method === "Page.loadEventFired" || (method === "Page.frameStoppedLoading" && main)) loaded = true;
// The debugger was detached: nothing more will arrive.
if (method === "Inspector.detached") navigating = loaded = true;
});
Expand All @@ -140,8 +145,10 @@ export class CdpBrowser implements FastModeBrowser {
const windowEnd = Date.now() + NAVIGATION_START_WINDOW_MS;
while (!navigating && Date.now() < windowEnd) await sleep(50);
if (navigating) {
// Heavy sites keep loading ads and trackers long after the page is
// usable: once its DOM is ready, give the full load only a little longer.
const deadline = Date.now() + LOAD_TIMEOUT_MS;
while (!loaded && Date.now() < deadline) await sleep(50);
while (!loaded && Date.now() < deadline && !(domReadyAt && Date.now() - domReadyAt > AFTER_DOM_READY_MS)) await sleep(50);
}
await sleep(SETTLE_MS);
} finally {
Expand Down
Loading
Loading