aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-08 00:18:15 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-08 00:18:15 +0530
commitaa08e740cb2f12cb424a2d042619955c99230ed7 (patch)
tree20c983aa38aaa34a61cb6f5aa273d153cb0df18a /src
parent4052e6e6afef1dc20146765963dea7c0289ca401 (diff)
feat: add per-tab isolation for multi-agent safety
Replace singleton dialog, pageInfoDirty, networkBuffer, and consoleBuffer with per-tab TabSession objects stored in a Map<string, TabSession>. - Add TabSession type (sessionId, targetId, dialog, pageInfoDirty, networkBuffer, consoleBuffer) and tabs/sessionIdToTargetId Maps - Add ev.sessionId filter guard in consumeEvents to prevent cross-tab event contamination - Rework switchTo to create/reuse TabSession without clearing buffers, updating sessionId on re-attach - Route all CDP events (dialog, network, console) through resolveTab to the correct TabSession - takeDialog/drainPageInfoInvalidations/drainNetworkBuffer/ drainConsoleBuffer read from current TabSession - Target.targetDestroyed prunes dead TabSession entries - attachFirstPage creates initial TabSession and prunes dead entries - Per-tab pageCaches Map in client.ts replaces pageCache singleton - listTabs reconciliation prunes dead pageCaches entries - console-buffer.ts clear() resets nextSeq to 1 (per-tab seq space) - Multi-agent warning in prompt.ts Parallel Execution section
Diffstat (limited to 'src')
-rw-r--r--src/cdp/console-buffer.ts4
-rw-r--r--src/cdp/session.ts146
-rw-r--r--src/client.ts21
-rw-r--r--src/prompt.ts2
4 files changed, 135 insertions, 38 deletions
diff --git a/src/cdp/console-buffer.ts b/src/cdp/console-buffer.ts
index 002ccd6..5ba7c87 100644
--- a/src/cdp/console-buffer.ts
+++ b/src/cdp/console-buffer.ts
@@ -256,10 +256,8 @@ export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
clear() {
records.clear();
+ nextSeq = 1;
overflowed = false;
- // nextSeq is intentionally NOT reset: the cursor remains monotonic across
- // tab switches so a stale sinceSeq from a previous tab can't accidentally
- // match new records.
},
};
};
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index c839842..ac19456 100644
--- a/src/cdp/session.ts
+++ b/src/cdp/session.ts
@@ -6,6 +6,19 @@ import type { CdpTransport } from "./transport";
import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
+// Per-tab state. One TabSession exists per known targetId.
+// Dialog, page-info dirty flag, and CDP buffers are per-tab — switching
+// tabs preserves the previous tab's data in its TabSession instead of
+// clearing it.
+type TabSession = {
+ sessionId: string;
+ targetId: string;
+ dialog: DialogInfo | null;
+ pageInfoDirty: boolean;
+ networkBuffer: ReturnType<typeof createNetworkBuffer>;
+ consoleBuffer: ReturnType<typeof createConsoleBuffer>;
+};
+
export type CdpSession = {
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
switchTo(targetId: string): Promise<Result<void, CdpError>>;
@@ -25,18 +38,33 @@ export const createCdpSession = (
): CdpSession => {
let sessionId: string | null = null;
let targetId: string | null = null;
- let dialog: DialogInfo | null = null;
- let pageInfoDirty = false;
- const networkBuffer = createNetworkBuffer();
- const consoleBuffer = createConsoleBuffer();
+
+ // Per-tab tracking: maps targetId → TabSession and sessionId → targetId for
+ // event routing. Lazy: a TabSession is created on the first visit to a tab.
+ const tabs = new Map<string, TabSession>();
+ const sessionIdToTargetId = new Map<string, string>();
let activeConsumer: Promise<void> = Promise.resolve();
+ // Event → TabSession resolver. Uses sessionId from CDP events to find the
+ // correct TabSession, falling back to the current targetId when no sessionId.
+ const resolveTab = (evSessionId?: string): TabSession | undefined => {
+ const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId;
+ return tid ? tabs.get(tid) : undefined;
+ };
+
const consumeEvents = async (): Promise<void> => {
for await (const ev of transport.events()) {
+ // Filter: skip events from sessions we're not currently tracking.
+ // Target.targetDestroyed is browser-level (no sessionId) — always process.
+ if (ev.method !== "Target.targetDestroyed") {
+ if (ev.sessionId && ev.sessionId !== sessionId) continue;
+ }
if (ev.method === "Page.javascriptDialogOpening") {
+ const tab = targetId ? tabs.get(targetId) : undefined;
+ if (!tab) continue;
const params = ev.params as Partial<DialogInfo> | undefined;
- dialog = {
+ tab.dialog = {
type: (params?.type as DialogInfo["type"]) ?? "alert",
message: params?.message ?? "",
...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
@@ -48,18 +76,40 @@ export const createCdpSession = (
// This prevents fast dismiss flows from dropping a dialog the agent
// was about to read. (Fix for spec §7 predictability bug #2.)
if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
- pageInfoDirty = true;
+ const tab = targetId ? tabs.get(targetId) : undefined;
+ if (tab) tab.pageInfoDirty = true;
}
if (ev.method === "Target.targetDestroyed" && ownership) {
const params = ev.params as { targetId?: string } | undefined;
- if (params?.targetId) ownership.remove(params.targetId);
+ if (params?.targetId) {
+ ownership.remove(params.targetId);
+ // Prune per-tab state for the destroyed target
+ const tab = tabs.get(params.targetId);
+ if (tab) {
+ sessionIdToTargetId.delete(tab.sessionId);
+ tabs.delete(params.targetId);
+ }
+ }
+ }
+ if (ev.method === "Network.requestWillBeSent") {
+ const tab = resolveTab(ev.sessionId);
+ if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
+ } else if (ev.method === "Network.responseReceived") {
+ const tab = resolveTab(ev.sessionId);
+ if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
+ } else if (ev.method === "Network.loadingFinished") {
+ const tab = resolveTab(ev.sessionId);
+ if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
+ } else if (ev.method === "Network.loadingFailed") {
+ const tab = resolveTab(ev.sessionId);
+ if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
+ } else if (ev.method === "Runtime.consoleAPICalled") {
+ const tab = resolveTab(ev.sessionId);
+ if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
+ } else if (ev.method === "Log.entryAdded") {
+ const tab = resolveTab(ev.sessionId);
+ if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
}
- if (ev.method === "Network.requestWillBeSent") networkBuffer.ingestRequestWillBeSent(ev.params);
- else if (ev.method === "Network.responseReceived") networkBuffer.ingestResponseReceived(ev.params);
- else if (ev.method === "Network.loadingFinished") networkBuffer.ingestLoadingFinished(ev.params);
- else if (ev.method === "Network.loadingFailed") networkBuffer.ingestLoadingFailed(ev.params);
- else if (ev.method === "Runtime.consoleAPICalled") consoleBuffer.ingestConsoleApi(ev.params);
- else if (ev.method === "Log.entryAdded") consoleBuffer.ingestLogEntry(ev.params);
}
};
@@ -77,7 +127,8 @@ export const createCdpSession = (
transport.onClose(() => {
sessionId = null;
targetId = null;
- pageInfoDirty = false;
+ tabs.clear();
+ sessionIdToTargetId.clear();
// Do NOT clear `dialog` here — same rationale as inside consumeEvents:
// the agent may have a pending takeDialog() call that should still see it.
restartConsumer();
@@ -116,6 +167,15 @@ export const createCdpSession = (
const hw = ownership.harnessWindow();
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
}
+ // Prune per-tab state for pages that no longer exist
+ const liveTargetIds = new Set(allPages.map((p) => p.targetId));
+ for (const tid of tabs.keys()) {
+ if (!liveTargetIds.has(tid)) {
+ const tab = tabs.get(tid);
+ if (tab) sessionIdToTargetId.delete(tab.sessionId);
+ tabs.delete(tid);
+ }
+ }
// Prefer attaching to a tab this session already owns. Falls back to
// creating a fresh harness-owned tab in a dedicated window — never
@@ -144,6 +204,15 @@ export const createCdpSession = (
sessionId = a.sessionId;
targetId = pickTargetId;
await enableDomains(a.sessionId);
+ tabs.set(pickTargetId, {
+ sessionId: a.sessionId,
+ targetId: pickTargetId,
+ dialog: null,
+ pageInfoDirty: false,
+ networkBuffer: createNetworkBuffer(),
+ consoleBuffer: createConsoleBuffer(),
+ });
+ sessionIdToTargetId.set(a.sessionId, pickTargetId);
return ok({ targetId: pickTargetId, sessionId: a.sessionId });
},
async switchTo(tid) {
@@ -152,14 +221,29 @@ export const createCdpSession = (
const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
if (!attached.success) return attached;
const a = attached.data as { sessionId: string };
- sessionId = a.sessionId;
+ // Reuse existing TabSession or create one on first visit
+ const existing = tabs.get(tid);
+ const tab: TabSession = existing ?? {
+ sessionId: a.sessionId,
+ targetId: tid,
+ dialog: null,
+ pageInfoDirty: true,
+ networkBuffer: createNetworkBuffer(),
+ consoleBuffer: createConsoleBuffer(),
+ };
+ if (existing) {
+ // Each Target.attachToTarget produces a new sessionId — update it.
+ sessionIdToTargetId.delete(existing.sessionId);
+ existing.sessionId = a.sessionId;
+ sessionIdToTargetId.set(a.sessionId, tid);
+ } else {
+ tabs.set(tid, tab);
+ await enableDomains(a.sessionId);
+ sessionIdToTargetId.set(a.sessionId, tid);
+ }
+ // Update global pointers to point at the new active tab
+ sessionId = tab.sessionId;
targetId = tid;
- pageInfoDirty = true;
- // Buffer is page-scoped: on switch, the network history of the previous
- // tab is no longer relevant to what the agent is now looking at.
- networkBuffer.clear();
- consoleBuffer.clear();
- await enableDomains(a.sessionId);
return ok(undefined);
},
current() {
@@ -175,20 +259,28 @@ export const createCdpSession = (
return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
},
takeDialog() {
- const d = dialog;
- dialog = null;
+ const tab = targetId ? tabs.get(targetId) : undefined;
+ if (!tab) return null;
+ const d = tab.dialog;
+ tab.dialog = null;
return d;
},
drainPageInfoInvalidations() {
- const dirty = pageInfoDirty;
- pageInfoDirty = false;
+ const tab = targetId ? tabs.get(targetId) : undefined;
+ if (!tab) return false;
+ const dirty = tab.pageInfoDirty;
+ tab.pageInfoDirty = false;
return dirty;
},
drainNetworkBuffer(filter) {
- return networkBuffer.drain(filter);
+ const tab = targetId ? tabs.get(targetId) : undefined;
+ if (!tab) return { records: [], total: 0, bufferOverflowed: false };
+ return tab.networkBuffer.drain(filter);
},
drainConsoleBuffer(filter) {
- return consoleBuffer.drain(filter);
+ const tab = targetId ? tabs.get(targetId) : undefined;
+ if (!tab) return { records: [], total: 0, bufferOverflowed: false };
+ return tab.consoleBuffer.drain(filter);
},
};
};
diff --git a/src/client.ts b/src/client.ts
index 2e451e8..f0eb016 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -98,7 +98,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
const session = createCdpSession(transport, ownership);
const mutationMutex = createMutex();
let lastHealth = 0;
- let pageCache: { readonly info: PageInfo; readonly at: number } | null = null;
+ let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
const start = async (): Promise<Result<void, CdpError>> => {
@@ -125,13 +125,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
return attached;
}
lastHealth = Date.now();
- pageCache = null;
+ pageCaches.clear();
return ok(undefined);
};
const stop = async (): Promise<void> => {
await transport.close();
- pageCache = null;
+ pageCaches.clear();
lastHealth = 0;
};
@@ -155,7 +155,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
const reattached = await session.attachFirstPage();
if (reattached.success) {
- pageCache = null;
+ pageCaches.clear();
return ok(undefined);
}
await stop();
@@ -185,7 +185,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => {
const dirty = session.drainPageInfoInvalidations();
- if (pageCache && !dirty && Date.now() - pageCache.at < PAGE_INFO_TTL_MS) return ok(pageCache.info);
+ const currentTid = session.current()?.targetId;
+ const cached = currentTid ? pageCaches.get(currentTid) : undefined;
+ if (cached && !dirty && Date.now() - cached.at < PAGE_INFO_TTL_MS) return ok(cached.info);
const expr = safeJs`JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
const raw = await evaluateJs(expr);
if (!raw.success) return raw;
@@ -198,7 +200,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
}
const info = parsePageInfoPayload(parsedRaw);
if (!info.success) return info;
- pageCache = { info: info.data, at: Date.now() };
+ if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() });
return ok(info.data);
};
@@ -228,13 +230,17 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
if (survivors.length !== owned.length) ownership.replaceAll(survivors);
const hw = ownership.harnessWindow();
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
+ // Prune per-tab page caches for tabs that no longer exist
+ for (const tid of pageCaches.keys()) {
+ if (!live.has(tid)) pageCaches.delete(tid);
+ }
return ok(tabs);
};
const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
const r = await session.switchTo(targetId);
if (!r.success) return r;
- pageCache = null;
+ // pageCache per-tab: no longer cleared — each tab retains its cache
// Best-effort: mark the tab title with a green circle so the user can
// see which tab the agent attached to. CSP or detached frames may
// block the eval; we don't surface that as a switchTab failure.
@@ -274,7 +280,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
if (url && url !== "about:blank") {
const nav = await session.call("Page.navigate", { url });
if (!nav.success) return nav;
- pageCache = null;
}
return ok(c.targetId);
};
diff --git a/src/prompt.ts b/src/prompt.ts
index 850d36f..ae94db3 100644
--- a/src/prompt.ts
+++ b/src/prompt.ts
@@ -74,6 +74,8 @@ browser_screenshot() + browser_page_info() + browser_execute_js("document.title"
browser_http_get("https://api.example.com/data") + browser_click(x, y)
\`\`\`
+**Multi-agent note**: When multiple subagents use the browser, tab switching by one agent changes the active tab for all agents. Per-tab data (console buffers, network traces, dialogs, page info cache) is isolated — switching tabs does not destroy another agent's collected data. Call \`browser_current_tab\` before mutation tools to confirm you're on the expected tab. If \`browser_page_info\` returns a dialog, handle it with \`browser_handle_dialog\` promptly — dialogs block page interaction and are not queued across agents.
+
### Common Patterns
**Navigation:**