diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-08 00:18:15 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-08 00:18:15 +0530 |
| commit | aa08e740cb2f12cb424a2d042619955c99230ed7 (patch) | |
| tree | 20c983aa38aaa34a61cb6f5aa273d153cb0df18a | |
| parent | 4052e6e6afef1dc20146765963dea7c0289ca401 (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
| -rw-r--r-- | src/cdp/console-buffer.ts | 4 | ||||
| -rw-r--r-- | src/cdp/session.ts | 146 | ||||
| -rw-r--r-- | src/client.ts | 21 | ||||
| -rw-r--r-- | src/prompt.ts | 2 |
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:** |
