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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ next-env.d.ts
# Screenshots (testing)
/screenshots
/extension/dist
/extension/.dev-bridge-token
35 changes: 29 additions & 6 deletions extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ npm run extension:build

Then open `chrome://extensions`, turn on **Developer mode**, click **Load unpacked**, and pick `extension/dist`. Click the WebPilot toolbar icon to open the side panel.

For development, `npm run extension:dev` rebuilds on every change; click the reload icon on the extension card to pick it up.
`extension:build` is the release build. For development use the dev build, which adds a test bridge (see Testing): `npm run extension:build:dev`, or `npm run extension:dev` to rebuild on every change.

## Using it

- Click the WebPilot icon, or press **Ctrl+Shift+Space** (change it at `chrome://extensions/shortcuts`), to open WebPilot **for the current tab**. Every tab has its own panel, conversation and running task; switching tabs switches panels, and a task keeps running if you close its panel or switch away.
- It's a conversation: follow-ups like "what about the second one?" or "open it" continue from the previous answers. Each turn also keeps hidden notes (pages visited, where it ended, what it clicked) so later messages can pick up from there, even after the tab has moved on.
- **History** (clock icon) lists past conversations. Opening one continues it in the current tab; WebPilot doesn't restore the old pages but knows what it did and navigates back if needed. **+** starts a new conversation.
- Enter sends, Shift+Enter adds a line, Esc stops a running task.

## First run

Expand All @@ -23,11 +30,13 @@ Fast mode (Jev) is on by default.

## How it works

- The agent runs in the side panel page and controls the active tab through `chrome.debugger` (the Chrome DevTools Protocol). Chrome shows a "WebPilot started debugging this browser" bar while a task runs; it goes away when the task ends, and clicking **Cancel** on it stops the task.
- WebPilot controls the tab you ask it to through `chrome.debugger` (the Chrome DevTools Protocol), attaching only when a task needs the page. Chrome shows a "WebPilot started debugging this browser" bar while a task runs; it goes away when the task ends, and clicking **Cancel** on it stops the task.
- Pages are read from Chrome's accessibility tree and acted on with real mouse and keyboard events, so sites treat them like your own input.
- The same core runs in the desktop app and the extension: `lib/core/run-task.ts` (the task flow), `lib/cdp/` (the CDP driver and accessibility-tree snapshot), `lib/jev/` (fast mode), `lib/safety.ts` and `lib/model-client.ts`.
- Requests that only find or read information use fast mode: Jev picks each click in ~0.3s and the model writes text and the answer. Requests that change something (buy, book, send, delete, submit) use the careful model planner.
- Before an irreversible click (buy, place order, checkout, send, delete, publish, unsubscribe...) the panel shows **"Hold on — this can't be undone"** with Allow and Cancel.
- The agent runs in the extension's background service worker (`src/engine.ts`), one session per tab; panels are views connected over a port. While a task runs, the worker keeps itself alive.
- Requests about the open page that reading alone can answer ("summarize this", "what does this say about X") skip navigation and clicks and stream the answer straight from the page. Other requests that only find or read information use fast mode: Jev picks each click in ~0.3s and the model writes text and the answer. Requests that change something (buy, book, send, delete, submit) use the careful model planner.
- Answers stream into the panel as they are written; Jev then checks them against the page, and an unsupported answer is redone (first by Jev looking closer, then by the model).
- Before an irreversible click (buy, place order, checkout, send, delete, publish, unsubscribe...) the panel shows **"Hold on — this can't be undone"** with Allow and Cancel. Cancel ends the task there; say "go ahead" to continue.

## Permissions

Expand All @@ -36,15 +45,15 @@ Fast mode (Jev) is on by default.
| `sidePanel` | The WebPilot panel |
| `debugger` | Reading and controlling the tab you ask it to work in |
| `tabs` | Knowing which tab is active and its title |
| `storage` | Your settings and OpenRouter key (stored locally) |
| `storage` | Your settings, OpenRouter key and conversation history (stored locally) |
| `identity` | "Connect OpenRouter" sign-in |
| `https://openrouter.ai/*` | Model and Jev requests |

Chrome does not allow extensions to control `chrome://` pages or the Chrome Web Store.

## Privacy

Page text, element labels and URLs of the tab WebPilot works in are sent to OpenRouter to run the model, and in fast mode to TypeSafe (through OpenRouter) for Jev. Nothing is sent while no task is running. Settings stay in the browser's local extension storage.
Page text, element labels and URLs of the tab WebPilot works in are sent to OpenRouter to run the model, and in fast mode to TypeSafe (through OpenRouter) for Jev. Nothing is sent while no task is running. Settings and conversation history (at most the 100 most recent conversations) stay in the browser's local extension storage on this device; they are never synced or uploaded. History has Clear all.

## Testing

Expand All @@ -61,3 +70,17 @@ The full scenario suite runs against the extension's engine too:
```bash
npm run bench:fast-mode -- --engine cdp --suite all
```

### Testing in your own browsers (dev bridge)

Dev builds include a bridge that lets the scenario harness run tasks inside the real browsers where the dev extension is installed, with their logins. Each task opens its own window and closes it afterwards, so your tabs are left alone.

```bash
node scripts/build-extension.mjs --dev --out ~/WebPilot-extension # load this folder once per browser
npx tsx scripts/extension-bridge.ts ping chrome # or edge, brave...
npx tsx scripts/extension-bridge.ts reload edge # pick up a rebuild without clicking
npx tsx scripts/extension-bridge.ts run chrome "summarize this page" --url https://example.com
npm run bench:fast-mode -- --engine extension --browser chrome --suite realistic --modes fast
```

The extension polls `http://127.0.0.1:4466` and only accepts commands from a server that knows the random secret generated for that build (`extension/.dev-bridge-token`, gitignored). Release builds (`npm run extension:build`) compile the bridge out and drop its permissions (`alarms`, localhost).
26 changes: 20 additions & 6 deletions extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,33 @@
"128": "icons/128x128.png"
},
"action": {
"default_title": "Open WebPilot",
"default_title": "Open WebPilot (Ctrl+Shift+Space)",
"default_icon": {
"16": "icons/16x16.png",
"32": "icons/32x32.png"
}
},
"side_panel": {
"default_path": "sidepanel.html"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["sidePanel", "debugger", "tabs", "storage", "identity"],
"host_permissions": ["https://openrouter.ai/*", "http://127.0.0.1/*", "http://localhost/*"]
"permissions": [
"sidePanel",
"debugger",
"tabs",
"storage",
"identity"
],
"host_permissions": [
"https://openrouter.ai/*"
],
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+Space",
"mac": "MacCtrl+Shift+Space"
},
"description": "Open WebPilot for this tab"
}
}
}
38 changes: 38 additions & 0 deletions extension/sidepanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,41 @@ button { font: inherit; color: inherit; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}

/* ---------- history ---------- */
.history-item {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
border: 1px solid var(--wp-border-soft);
border-radius: var(--wp-radius-md);
background: var(--wp-surface);
animation: rise 0.2s ease-out both;
}
.history-item.current { border-color: var(--wp-accent-glow); }
.history-open {
flex: 1;
min-width: 0;
text-align: left;
padding: 8px 10px;
border: 0;
background: transparent;
cursor: pointer;
}
.history-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-open small { display: block; color: var(--wp-text-faint); font-size: 11px; }
.history-item:hover { border-color: var(--wp-border); }

/* Answer text still streaming in. */
.answer.streaming > :last-child::after {
content: "";
display: inline-block;
width: 7px;
height: 13px;
margin-left: 3px;
vertical-align: -2px;
background: var(--wp-accent);
animation: blink 0.9s steps(2) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
41 changes: 35 additions & 6 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
/// <reference types="chrome" />

// The toolbar button opens WebPilot in the side panel. The agent itself runs in
// the side panel page, which stays alive while it is open (MV3 service workers
// are suspended when idle, so they cannot host a long-running task).
import { startDevBridge } from "./dev-bridge";
import { Engine } from "./engine";
import { PANEL_PORT_PREFIX } from "./protocol";

declare const __DEV_BRIDGE__: boolean;

const engine = new Engine();

/**
* Every tab gets its own side panel (and its own conversation): clicking the
* toolbar button or pressing the shortcut opens the panel for the current tab
* only. Both calls stay synchronous so they run inside the user gesture.
*/
function openPanel(tabId: number) {
chrome.sidePanel.setOptions({ tabId, path: `sidepanel.html?tabId=${tabId}`, enabled: true }).catch(() => {});
chrome.sidePanel.open({ tabId }).catch(() => {});
}

chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});
// Earlier versions opened one shared panel on click; per-tab panels are opened here instead.
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }).catch(() => {});
});

chrome.action.onClicked.addListener((tab) => {
if (tab.id !== undefined) openPanel(tab.id);
});
chrome.runtime.onStartup.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});

chrome.runtime.onConnect.addListener((port) => {
if (!port.name.startsWith(PANEL_PORT_PREFIX)) return;
const tabId = Number(port.name.slice(PANEL_PORT_PREFIX.length));
if (Number.isInteger(tabId)) engine.connect(port, tabId);
});

chrome.tabs.onRemoved.addListener((tabId) => {
engine.dispose(tabId);
});

if (__DEV_BRIDGE__) startDevBridge(engine);
51 changes: 51 additions & 0 deletions extension/src/conversations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/// <reference types="chrome" />

/**
* Local conversation history: each conversation (including the hidden browser
* memory of its turns) is stored in chrome.storage.local, on this device only,
* never synced. An index lists them for the History view; only the most recent
* MAX_CONVERSATIONS are kept.
*/

import type { Conversation, ConversationSummary } from "./protocol";

const INDEX_KEY = "webpilot.conversations";
const CONVERSATION_PREFIX = "webpilot.conversation.";
const MAX_CONVERSATIONS = 100;
const TITLE_LENGTH = 70;

export function newConversationId(): string {
return `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
}

export async function listConversations(): Promise<ConversationSummary[]> {
const index = (await chrome.storage.local.get(INDEX_KEY))[INDEX_KEY] as ConversationSummary[] | undefined;
return (index || []).slice().sort((left, right) => right.updatedAt - left.updatedAt);
}

export async function loadConversation(id: string): Promise<Conversation | null> {
const key = `${CONVERSATION_PREFIX}${id}`;
return ((await chrome.storage.local.get(key))[key] as Conversation | undefined) || null;
}

export async function saveConversation(conversation: Conversation): Promise<void> {
if (!conversation.turns.length) return;
const title = conversation.turns[0].user.replace(/\s+/g, " ").slice(0, TITLE_LENGTH);
const saved: Conversation = { ...conversation, title, turns: conversation.turns.map((turn) => ({ ...turn, streaming: undefined })) };
const summary: ConversationSummary = { id: saved.id, title, updatedAt: saved.updatedAt, turnCount: saved.turns.length };
const index = [summary, ...(await listConversations()).filter((item) => item.id !== saved.id)];
const dropped = index.slice(MAX_CONVERSATIONS);
await chrome.storage.local.set({ [`${CONVERSATION_PREFIX}${saved.id}`]: saved, [INDEX_KEY]: index.slice(0, MAX_CONVERSATIONS) });
if (dropped.length) await chrome.storage.local.remove(dropped.map((item) => `${CONVERSATION_PREFIX}${item.id}`));
}

export async function deleteConversation(id: string): Promise<void> {
const index = (await listConversations()).filter((item) => item.id !== id);
await chrome.storage.local.set({ [INDEX_KEY]: index });
await chrome.storage.local.remove(`${CONVERSATION_PREFIX}${id}`);
}

export async function clearConversations(): Promise<void> {
const index = await listConversations();
await chrome.storage.local.remove([INDEX_KEY, ...index.map((item) => `${CONVERSATION_PREFIX}${item.id}`)]);
}
Loading
Loading