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
2 changes: 1 addition & 1 deletion packages/chrome-extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "Markus Browser Automation",
"description": "Enables Markus AI agents to automate browser tasks without the remote debugging dialog.",
"version": "1.0.0",
"permissions": ["debugger", "tabs", "activeTab", "scripting"],
"permissions": ["debugger", "tabs", "activeTab", "scripting", "storage"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "dist/background.js",
Expand Down
16 changes: 11 additions & 5 deletions packages/chrome-extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@

// Clean up page state when tabs are closed
chrome.tabs.onRemoved.addListener((tabId) => {
const pageId = pm.peekPageId(tabId);
pm.removeByTabId(tabId);
client.send({ event: 'tab_closed', data: { tabId } });
client.send({ event: 'tab_closed', data: { tabId, pageId } });
});

// Handle debugger detach events
Expand All @@ -50,7 +51,12 @@
}
});

// Connect to bridge
client.connect();

console.log('[Markus] Browser automation extension initialized');
// Restore PM state from chrome.storage.session (survives service worker restarts),
// then connect to bridge.
pm.restore().then((restored) => {
if (restored) {
console.log('[Markus] Reconnecting with restored page state');

Check warning on line 58 in packages/chrome-extension/src/background.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected console statement. Only these console methods are allowed: warn, error
}
client.connect();
console.log('[Markus] Browser automation extension initialized');

Check warning on line 61 in packages/chrome-extension/src/background.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected console statement. Only these console methods are allowed: warn, error
});
33 changes: 33 additions & 0 deletions packages/chrome-extension/src/debugger-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Shared debugger attachment helper.
* Handles state desynchronization caused by service worker restarts:
* the PM's in-memory `debuggerAttached` set may have been cleared while
* Chrome's actual debugger connections persist.
*/

import type { PageManager } from './page-manager.js';

export async function ensureDebugger(pm: PageManager, tabId: number): Promise<void> {
if (pm.isDebuggerAttached(tabId)) return;

try {
await chrome.debugger.attach({ tabId }, '1.3');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('already attached') || msg.includes('Another debugger')) {
// Chrome says attached but PM disagrees → stale PM state after SW restart.
// Re-sync: mark attached and enable domains (they may also be stale).
pm.setDebuggerAttached(tabId, true);
try {
await chrome.debugger.sendCommand({ tabId }, 'Page.enable');
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
} catch { /* domains may already be enabled */ }
return;
}
throw err;
}

pm.setDebuggerAttached(tabId, true);
await chrome.debugger.sendCommand({ tabId }, 'Page.enable');
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
}
91 changes: 91 additions & 0 deletions packages/chrome-extension/src/page-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,119 @@
private nextPageId = 1;
private _selectedPageId: number | null = null;
private debuggerAttached = new Set<number>();
private persistTimer: ReturnType<typeof setTimeout> | null = null;

get selectedPageId(): number | null { return this._selectedPageId; }
get selectedTabId(): number | null {
if (this._selectedPageId === null) return null;
return this.pageToTab.get(this._selectedPageId) ?? null;
}

/**
* Restore PM state from chrome.storage.session.
* Called on service worker startup to recover page↔tab mappings
* that would otherwise be lost when the service worker is terminated.
*/
async restore(): Promise<boolean> {
try {
const data = await chrome.storage.session.get('pm_state');
if (!data.pm_state) return false;
const s = data.pm_state as {
tabToPage: [number, number][];
pageToTab: [number, number][];
nextPageId: number;
selectedPageId: number | null;
};

// Verify tabs still exist before restoring mappings
const liveTabs = new Set<number>();
try {
for (const tab of await chrome.tabs.query({})) {
if (tab.id) liveTabs.add(tab.id);
}
} catch { /* ignore */ }

this.tabToPage = new Map();
this.pageToTab = new Map();
let maxId = 0;
for (const [tabId, pageId] of s.tabToPage) {
if (liveTabs.has(tabId)) {
this.tabToPage.set(tabId, pageId);
this.pageToTab.set(pageId, tabId);
if (pageId > maxId) maxId = pageId;
}
}
this.nextPageId = Math.max(s.nextPageId, maxId + 1);
this._selectedPageId =
s.selectedPageId !== null && this.pageToTab.has(s.selectedPageId)
? s.selectedPageId
: null;

console.log(`[Markus] PM restored: ${this.tabToPage.size} pages, nextId=${this.nextPageId}`);

Check warning on line 61 in packages/chrome-extension/src/page-manager.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected console statement. Only these console methods are allowed: warn, error
return true;
} catch (err) {
console.warn('[Markus] PM restore failed:', err);
return false;
}
}

private schedulePersist(): void {
if (this.persistTimer) return;
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
chrome.storage.session.set({
pm_state: {
tabToPage: [...this.tabToPage.entries()],
pageToTab: [...this.pageToTab.entries()],
nextPageId: this.nextPageId,
selectedPageId: this._selectedPageId,
},
}).catch(() => { /* ignore persist failures */ });
}, 50);
}

getPageId(tabId: number): number {
let pageId = this.tabToPage.get(tabId);
if (pageId === undefined) {
pageId = this.nextPageId++;
this.tabToPage.set(tabId, pageId);
this.pageToTab.set(pageId, tabId);
this.schedulePersist();
}
return pageId;
}

/** Read-only lookup: returns existing pageId for a tabId, or undefined. */
peekPageId(tabId: number): number | undefined {
return this.tabToPage.get(tabId);
}

getTabId(pageId: number): number | undefined {
return this.pageToTab.get(pageId);
}

/**
* Resolve which tab to operate on. If params contains `_pageId`, use that
* explicit page (multi-agent safe). Otherwise fall back to the global
* selectedTabId (legacy / npx compat).
*/
resolveTabId(params: Record<string, unknown>): number {
const pageId = params._pageId as number | undefined;
if (pageId !== undefined) {
const tabId = this.pageToTab.get(pageId);
if (tabId === undefined) throw new Error(`Page ${pageId} not found.`);
return tabId;
}
if (this._selectedPageId === null) throw new Error('No page selected. Call new_page or select_page first.');
const tabId = this.pageToTab.get(this._selectedPageId);
if (tabId === undefined) throw new Error('No page selected. Call new_page or select_page first.');
return tabId;
}

selectPage(pageId: number): boolean {
if (!this.pageToTab.has(pageId)) return false;
this._selectedPageId = pageId;
this.schedulePersist();
return true;
}

Expand All @@ -47,6 +136,7 @@
if (this._selectedPageId === pageId) {
this._selectedPageId = null;
}
this.schedulePersist();
}

removeByTabId(tabId: number): void {
Expand Down Expand Up @@ -78,5 +168,6 @@
this.debuggerAttached.clear();
this._selectedPageId = null;
this.nextPageId = 1;
this.schedulePersist();
}
}
14 changes: 13 additions & 1 deletion packages/chrome-extension/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
private _connected = false;
private requestQueue: Promise<void> = Promise.resolve();

constructor(url?: string) {
this.url = url ?? DEFAULT_URL;
Expand All @@ -50,7 +51,7 @@
}

this.ws.onopen = () => {
console.log('[Markus] Connected to bridge');

Check warning on line 54 in packages/chrome-extension/src/protocol.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected console statement. Only these console methods are allowed: warn, error
this._connected = true;
this.startKeepalive();
chrome.action.setIcon({ path: {
Expand All @@ -61,7 +62,7 @@
};

this.ws.onclose = () => {
console.log('[Markus] Disconnected from bridge');

Check warning on line 65 in packages/chrome-extension/src/protocol.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected console statement. Only these console methods are allowed: warn, error
this._connected = false;
this.stopKeepalive();
chrome.action.setTitle({ title: 'Markus Browser Automation (Disconnected)' });
Expand All @@ -75,13 +76,24 @@
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string) as BridgeRequest;
this.handleRequest(msg);
this.enqueueRequest(msg);
} catch (err) {
console.error('[Markus] Failed to parse message:', err);
}
};
}

/**
* Serialize all incoming requests so only one tool runs at a time.
* Prevents race conditions on shared PageManager state (selectedPageId)
* when multiple agents issue concurrent tool calls.
*/
private enqueueRequest(req: BridgeRequest): void {
this.requestQueue = this.requestQueue
.then(() => this.handleRequest(req))
.catch((err) => console.error('[Markus] Request queue error:', err));
}

private async handleRequest(req: BridgeRequest): Promise<void> {
const handler = this.handlers.get(req.method);
if (!handler) {
Expand Down
33 changes: 10 additions & 23 deletions packages/chrome-extension/src/tools/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,12 @@
*/

import type { PageManager } from '../page-manager.js';
import { ensureDebugger } from '../debugger-helper.js';

async function cdp(tabId: number, method: string, params?: Record<string, unknown>): Promise<unknown> {
return chrome.debugger.sendCommand({ tabId }, method, params);
}

async function ensureDebugger(pm: PageManager, tabId: number): Promise<void> {
if (pm.isDebuggerAttached(tabId)) return;
await chrome.debugger.attach({ tabId }, '1.3');
pm.setDebuggerAttached(tabId, true);
await chrome.debugger.sendCommand({ tabId }, 'Page.enable');
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
}

function requireSelectedTab(pm: PageManager): number {
const tabId = pm.selectedTabId;
if (tabId === null) throw new Error('No page selected. Call new_page or select_page first.');
return tabId;
}

/**
* Resolve a snapshot uid to DOM coordinates via JS evaluation.
* The uid corresponds to an aria-snapshot element with a data-uid attribute
Expand Down Expand Up @@ -74,7 +61,7 @@ export function registerInputTools(
register('click', async (params) => {
const uid = params.uid as string;
if (!uid) throw new Error('uid is required');
const tabId = requireSelectedTab(pm);
const tabId = pm.resolveTabId(params);
await ensureDebugger(pm, tabId);

const { x, y } = await resolveUidToCoords(tabId, uid);
Expand All @@ -87,7 +74,7 @@ export function registerInputTools(
const value = params.value as string;
if (!uid) throw new Error('uid is required');
if (value === undefined) throw new Error('value is required');
const tabId = requireSelectedTab(pm);
const tabId = pm.resolveTabId(params);
await ensureDebugger(pm, tabId);

const { x, y } = await resolveUidToCoords(tabId, uid);
Expand All @@ -102,7 +89,7 @@ export function registerInputTools(
register('fill_form', async (params) => {
const fields = params.fields as Array<{ uid: string; value: string }>;
if (!fields || !Array.isArray(fields)) throw new Error('fields array is required');
const tabId = requireSelectedTab(pm);
const tabId = pm.resolveTabId(params);
await ensureDebugger(pm, tabId);

const results: string[] = [];
Expand All @@ -120,7 +107,7 @@ export function registerInputTools(
register('type_text', async (params) => {
const text = params.text as string;
if (!text) throw new Error('text is required');
const tabId = requireSelectedTab(pm);
const tabId = pm.resolveTabId(params);
await ensureDebugger(pm, tabId);

await cdp(tabId, 'Input.insertText', { text });
Expand All @@ -130,7 +117,7 @@ export function registerInputTools(
register('press_key', async (params) => {
const key = params.key as string;
if (!key) throw new Error('key is required');
const tabId = requireSelectedTab(pm);
const tabId = pm.resolveTabId(params);
await ensureDebugger(pm, tabId);

const parts = key.split('+');
Expand All @@ -157,7 +144,7 @@ export function registerInputTools(
register('hover', async (params) => {
const uid = params.uid as string;
if (!uid) throw new Error('uid is required');
const tabId = requireSelectedTab(pm);
const tabId = pm.resolveTabId(params);
await ensureDebugger(pm, tabId);

const { x, y } = await resolveUidToCoords(tabId, uid);
Expand All @@ -171,7 +158,7 @@ export function registerInputTools(
const fromUid = params.from_uid as string ?? params.fromUid as string;
const toUid = params.to_uid as string ?? params.toUid as string;
if (!fromUid || !toUid) throw new Error('from_uid and to_uid are required');
const tabId = requireSelectedTab(pm);
const tabId = pm.resolveTabId(params);
await ensureDebugger(pm, tabId);

const from = await resolveUidToCoords(tabId, fromUid);
Expand All @@ -194,7 +181,7 @@ export function registerInputTools(
register('handle_dialog', async (params) => {
const accept = params.accept !== false;
const promptText = params.promptText as string | undefined;
const tabId = requireSelectedTab(pm);
const tabId = pm.resolveTabId(params);
await ensureDebugger(pm, tabId);

await cdp(tabId, 'Page.handleJavaScriptDialog', {
Expand All @@ -208,7 +195,7 @@ export function registerInputTools(
const uid = params.uid as string;
const filePath = params.filePath as string ?? params.file_path as string;
if (!uid || !filePath) throw new Error('uid and filePath are required');
const tabId = requireSelectedTab(pm);
const tabId = pm.resolveTabId(params);
await ensureDebugger(pm, tabId);

// Resolve uid to DOM node
Expand Down
Loading
Loading