diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | .rpiv/artifacts/designs/2026-05-07_00-58_per-tab-isolation.md | 507 | ||||
| -rw-r--r-- | .rpiv/artifacts/plans/2026-05-07_18-15-35_per-tab-isolation.md | 452 | ||||
| -rw-r--r-- | .rpiv/artifacts/questions/2026-05-07_00-26_multi-agent-safety.md | 40 | ||||
| -rw-r--r-- | .rpiv/artifacts/research/2026-05-07_00-41_multi-agent-safety.md | 223 |
5 files changed, 2 insertions, 1223 deletions
@@ -5,9 +5,10 @@ benchmark/ .DS_Store *.log docs/ +.rpiv/ scripts/* !scripts/check-boundaries.ts !scripts/boundary-allowlist.ts data/ .worktrees/ -.harness/ +.harness/
\ No newline at end of file diff --git a/.rpiv/artifacts/designs/2026-05-07_00-58_per-tab-isolation.md b/.rpiv/artifacts/designs/2026-05-07_00-58_per-tab-isolation.md deleted file mode 100644 index 9dda4ff..0000000 --- a/.rpiv/artifacts/designs/2026-05-07_00-58_per-tab-isolation.md +++ /dev/null @@ -1,507 +0,0 @@ ---- -date: 2026-05-07T00:58:00Z -designer: amankumarsingh77 -git_commit: bd19739 -branch: main -repository: pi-browser-harness -topic: "per-tab-isolation-multi-agent-safety" -tags: [design, session, isolation, per-tab, buffers, dialog, safety, concurrency] -status: complete -research_source: "thoughts/shared/research/2026-05-07_00-41_multi-agent-safety.md" -last_updated: 2026-05-07 -last_updated_by: amankumarsingh77 ---- - -# Design: Per-Tab Isolation for Multi-Agent Safety - -## Summary - -Add `TabSession` — a per-targetId state object in `session.ts` containing sessionId, targetId, dialog, pageInfoDirty, networkBuffer, and consoleBuffer — stored in a `Map<string, TabSession>`. The `consumeEvents` CDP event loop gains an `ev.sessionId` filter that routes events to the correct tab's buffer. `switchTo` preserves old tab state instead of clearing buffers. `client.ts` page cache becomes `Map<targetId, Cache>`. Delivered in two phases: Phase 1 (immediate `ev.sessionId` filter) and Phase 2 (full per-tab data structures). Phase 3 (agent-aware tab locking) is deferred. - -## Requirements - -- Tab switching must not clear another tab's network/console buffer data -- Dialog from tab A must not overwrite dialog from tab B -- Page info dirty flag must be per-tab, not global -- Console `sinceSeq` cursor must remain valid across tab switches (no cross-tab data leaks) -- Network buffer data must survive round-trip tab switches (A→B→A) -- Page info cache must be preserved per-tab across switches -- CDP events from unattached sessions must be filtered (no cross-contamination) -- Dead tab state must be pruned on target destruction and session reconciliation - -## Current State Analysis - -### Key Discoveries - -- `src/cdp/session.ts:41-46` — Six mutable singletons: `sessionId`, `targetId`, `dialog`, `pageInfoDirty`, `networkBuffer`, `consoleBuffer`. All overwritten on `switchTo` (`session.ts:125-143`). -- `src/cdp/session.ts:136-137` — `networkBuffer.clear()` and `consoleBuffer.clear()` destroy all prior-tab data on every tab switch. -- `src/cdp/session.ts:33` — `consumeEvents` has no `ev.sessionId` filtering — events from all attached sessions route to the same singletons. -- `src/cdp/console-buffer.ts:256-262` — `clear()` preserves `nextSeq` (claiming safety), but this causes cross-tab data leaks for stale cursors. The fix: reset `nextSeq` on clear. -- `src/cdp/session.ts:150-151` — `callOnTarget()` escape hatch exists but is unused by observation tools — deferred to Phase 3. -- `src/client.ts:85` — Single `pageCache` nullified on any `switchTab` (`client.ts:183`). -- `src/cdp/ownership.ts:38-72` — OwnershipRegistry is the canonical pattern for per-target tracking: add/remove/has/list/replaceAll/onChange factory. - -### Patterns to Follow - -| New structure | Model after | File:Line | -|---|---|---| -| `TabSession` factory | `createOwnershipRegistry` | `ownership.ts:38-72` | -| `Map<string, TabSession>` | `ownership.ts:29` — `Set<string>` | `ownership.ts:29` | -| Per-tab page cache `Map<targetId, {info, at}>` | `client.ts:85,168-179` — single `pageCache` | `client.ts:85` | -| Buffer preservation on switch | `d3d86f9` — dialog preserved on close | `session.ts:78-83` | -| Dead TabSession pruning | `session.ts:112-118` — ownership reconciliation | `session.ts:112-118` | - -## Scope - -### Building -- Phase 1: `ev.sessionId` filter in `consumeEvents` — 1-line guard preventing cross-tab event contamination -- Phase 2: `TabSession` type + `Map<string, TabSession>` in `session.ts` -- Phase 2: Per-tab dialog (move `dialog` into TabSession) -- Phase 2: Per-tab `pageInfoDirty` (move flag into TabSession) -- Phase 2: Per-tab buffers (move `networkBuffer`/`consoleBuffer` into TabSession, preserve on switch) -- Phase 2: Per-tab page cache in `client.ts` (`Map<targetId, { info, at }>`) -- Phase 2: Console `nextSeq` reset on `clear()` in `console-buffer.ts` -- Phase 2: TabSession cleanup on `Target.targetDestroyed` and reconciliation -- Phase 2: Multi-agent warning in `prompt.ts` Parallel Execution section - -### Not Building -- Agent-aware tab locking (requires pi platform agent identity exposure — Phase 3) -- Observation tool `callOnTarget` refactoring (Phase 3) -- Per-agent BrowserClient instances -- New CDP transport connections per agent - -## Decisions - -### TabSession model — one object per targetId -**Evidence**: `OwnershipRegistry` at `ownership.ts:29` uses a single `Set` with add/remove/has — one collection, not separate Maps per field. Grouping all per-tab state into one `TabSession` object per targetId follows this pattern and simplifies cleanup (one delete removes all state). -**Decision**: Single `TabSession` object per targetId stored in `Map<string, TabSession>`. - -### ev.sessionId filter — immediate Phase 1 -**Evidence**: Research Finding 9 identifies this as "the single most impactful change." Precedent-locator confirms it's the essential first move. One line in `consumeEvents` prevents dialog overwrites, dirty-flag theft, and buffer pollution. -**Decision**: Phase 1 delivers only this filter. Phase 2 builds per-tab structures on top. - -### Buffers preserved, not cleared on switchTo -**Evidence**: `d3d86f9` established the pattern of NOT clearing dialog on transport close (`session.ts:78-83`) — data survives events that would otherwise destroy it. The same rationale applies to buffers on tab switch. Research confirms buffer clearing invalidates per-agent cursors. -**Decision**: `switchTo` no longer calls `networkBuffer.clear()` or `consoleBuffer.clear()`. Each tab's buffers persist in its TabSession. - -### nextSeq reset on clear -**Evidence**: `console-buffer.ts:256-262` comment claims monotonic `nextSeq` prevents stale cursor matches — research Finding 4 proves the opposite. With per-tab buffers, each tab has independent seq space, eliminating the need for global monotonicity. -**Decision**: `clear()` resets `nextSeq` to 1. - -### drain methods default to current tab — no API break -**Evidence**: Integration scanner confirms domain tools call `session.drainNetworkBuffer()`, `session.drainConsoleBuffer()`, `client.pageInfo()` which calls `session.takeDialog()` and `session.drainPageInfoInvalidations()`. All without parameters. -**Decision**: All drain/take methods accept optional `targetId` parameter defaulting to the current tab. Existing callers unchanged. - -### pageCache stays in client.ts as Map -**Evidence**: `pageCache` at `client.ts:85` already has TTL, parse, and invalidation logic in `client.ts:168-179`. Moving it to session.ts would require exposing TTL constants and parse logic across module boundaries. Session-level dirty flag (TabSession.pageInfoDirty) is sufficient for invalidation. -**Decision**: pageCache in `client.ts` becomes `Map<targetId, { info: PageInfo; at: number }>`. TabSession carries the dirty flag. - -## Architecture - - - -### src/cdp/session.ts:1-194 — MODIFY - -```typescript -// === Slice 1: Foundation === -// All changes are within createCdpSession(). Existing singleton vars (dialog, -// pageInfoDirty, networkBuffer, consoleBuffer) remain for Slice 1 compatibility. - -// NEW: Per-tab state type (inserted before createCdpSession, after imports) -type TabSession = { - sessionId: string; - targetId: string; - dialog: DialogInfo | null; - pageInfoDirty: boolean; - networkBuffer: ReturnType<typeof createNetworkBuffer>; - consoleBuffer: ReturnType<typeof createConsoleBuffer>; -}; - -// Inside createCdpSession(), ADD after the singleton declarations: -const tabs = new Map<string, TabSession>(); -const sessionIdToTargetId = new Map<string, string>(); - -// consumeEvents — ADD filter guard as the FIRST statement inside the for-await loop: -// (inserted before the existing `if (ev.method === "Page.javascriptDialogOpening")`) -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; - } - // ... all existing event handlers unchanged ... -} - -// switchTo — REPLACE the entire method body: -async switchTo(tid) { - const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null }); - if (!activated.success) return activated; - 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 }; - // 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) { - tabs.set(tid, tab); - await enableDomains(a.sessionId); - } - // Update global pointers to point at the new active tab - sessionId = tab.sessionId; - targetId = tid; - // Update sessionId→targetId reverse lookup for event filtering - sessionIdToTargetId.set(tab.sessionId, tid); - return ok(undefined); -} - -// attachFirstPage — ADD after `sessionId = a.sessionId; targetId = pickTargetId;`: -tabs.set(pickTargetId, { - sessionId: a.sessionId, - targetId: pickTargetId, - dialog: null, - pageInfoDirty: false, - networkBuffer: createNetworkBuffer(), - consoleBuffer: createConsoleBuffer(), -}); -sessionIdToTargetId.set(a.sessionId, pickTargetId); - -// transport.onClose — ADD Map cleanup before restartConsumer(): -tabs.clear(); -sessionIdToTargetId.clear(); -``` - -### src/cdp/session.ts — Slice 2 additions (dialog + dirty flag into TabSession) - -```typescript -// consumeEvents — dialog handler: write to TabSession instead of singleton -if (ev.method === "Page.javascriptDialogOpening") { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) continue; - const params = ev.params as Partial<DialogInfo> | undefined; - tab.dialog = { - type: (params?.type as DialogInfo["type"]) ?? "alert", - message: params?.message ?? "", - ...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}), - }; - continue; -} - -// consumeEvents — dirty flag: write to TabSession instead of singleton -if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") { - const tab = targetId ? tabs.get(targetId) : undefined; - if (tab) tab.pageInfoDirty = true; -} - -// takeDialog — read from current TabSession -takeDialog() { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) return null; - const d = tab.dialog; - tab.dialog = null; - return d; -}, - -// drainPageInfoInvalidations — read from current TabSession -drainPageInfoInvalidations() { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) return false; - const dirty = tab.pageInfoDirty; - tab.pageInfoDirty = false; - return dirty; -}, - -// REMOVE the singleton `dialog` and `pageInfoDirty` declarations: -// - let dialog: DialogInfo | null = null; ← REMOVED (now in TabSession) -// - let pageInfoDirty = false; ← REMOVED (now in TabSession) - -// transport.onClose — dialog preservation moves to TabSession (no change needed; -// tabs.clear() already clears all TabSession state including dialog) -``` - -### src/cdp/console-buffer.ts:256-262 — MODIFY (Slice 3) - -```typescript -// Replace the clear() method body — reset nextSeq to 1 on clear: -// OLD: -// clear() { -// records.clear(); -// 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. -// }, -// NEW: - clear() { - records.clear(); - nextSeq = 1; - overflowed = false; - }, -// Rationale: With per-tab buffers, each TabSession has its own console buffer -// with independent sequence space. Resetting nextSeq on clear prevents cross-tab -// data leaks — a stale cursor from tab X (seq 15) cannot match new records from -// tab Y (which restart at seq 1). The old monotonic-across-tabs behavior was a -// design inversion when buffers were shared. -``` - -### src/cdp/session.ts — Slice 3 additions (buffer routing + drain from TabSession) - -```typescript -// consumeEvents — route network/console events to correct TabSession: -// Helper (inserted before the for-await loop): -const resolveTab = (evSessionId?: string): TabSession | undefined => { - const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId; - return tid ? tabs.get(tid) : undefined; -}; - -// Replace all singleton buffer ingest calls with routed calls: -// OLD: if (ev.method === "Network.requestWillBeSent") networkBuffer.ingestRequestWillBeSent(ev.params); -// NEW: -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); -} - -// drainNetworkBuffer — read from current TabSession -drainNetworkBuffer(filter) { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) return { records: [], total: 0, bufferOverflowed: false }; - return tab.networkBuffer.drain(filter); -}, - -// drainConsoleBuffer — read from current TabSession -drainConsoleBuffer(filter) { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) return { records: [], total: 0, bufferOverflowed: false }; - return tab.consoleBuffer.drain(filter); -}, - -// REMOVE the singleton buffer declarations: -// - const networkBuffer = createNetworkBuffer(); ← REMOVED (now in TabSession) -// - const consoleBuffer = createConsoleBuffer(); ← REMOVED (now in TabSession) -``` - -### src/client.ts:85,157-180,183 — MODIFY (Slice 2) - -```typescript -// REPLACE single pageCache with per-tab Map: -// OLD: let pageCache: { readonly info: PageInfo; readonly at: number } | null = null; -// NEW: -const pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>(); - -// readPageInfo — use per-tab Map instead of singleton: -const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => { - const dirty = session.drainPageInfoInvalidations(); - 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; - if (typeof raw.data !== "string") return err(cdpError("invalid_response", "page info evaluation did not return a string")); - let parsedRaw: unknown; - try { - parsedRaw = JSON.parse(raw.data); - } catch (e) { - return err(cdpError("invalid_response", `page info JSON.parse failed: ${e instanceof Error ? e.message : String(e)}`)); - } - const info = parsePageInfoPayload(parsedRaw); - if (!info.success) return info; - if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() }); - return ok(info.data); -}; - -// switchTab — REMOVE `pageCache = null` (no longer needed — each tab retains its cache): -const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => { - const r = await session.switchTo(targetId); - if (!r.success) return r; - // pageCache = null; ← REMOVED - await session.call("Runtime.evaluate", { - expression: safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`, - }); - return ok(undefined); -}; - -// stop — REMOVE `pageCache = null`, replace with Map.clear(): -const stop = async (): Promise<void> => { - await transport.close(); - pageCaches.clear(); - lastHealth = 0; -}; -``` - -### src/cdp/session.ts — Slice 4 additions (TabSession cleanup) - -```typescript -// consumeEvents — Target.targetDestroyed: remove TabSession entries -if (ev.method === "Target.targetDestroyed" && ownership) { - const params = ev.params as { targetId?: string } | undefined; - 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); - } - } -} - -// attachFirstPage — ADD TabSession pruning after ownership reconciliation: -// (inserted after the ownership reconciliation block, after ownership.setHarnessWindow) -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); - } -} - -// listTabs in client.ts — ADD page cache pruning (extend existing reconciliation): -// After: const survivors = owned.filter((id) => live.has(id)); -// Add: -for (const tid of pageCaches.keys()) { - if (!live.has(tid)) pageCaches.delete(tid); -} -``` - -### src/prompt.ts:63-73 — MODIFY (Slice 4) - -```typescript -// REPLACE the "Parallel Execution" section (lines 63-73): - -### Parallel Execution - -Observation tools (browser_screenshot, browser_page_info, browser_execute_js, browser_list_tabs, browser_http_get, etc.) can run in parallel with each other and with mutation tools. The harness automatically serializes mutation tools (click, type, scroll, navigate, switch_tab, etc.) so they never race on shared state. When operations are independent, emit them in the same turn for better performance. - -**Examples of safe parallel calls:** -\`\`\` -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. - -## Desired End State - -```typescript -// Tab A and Tab B can coexist without cross-contamination: - -// Agent on Tab A: -browser_navigate("https://site-a.com") // navigates Tab A -browser_console({ sinceSeq: 0 }) // gets Tab A's console, nextCursor=5 -// Agent switches to Tab B: -browser_switch_tab("targetId_B") -browser_navigate("https://site-b.com") // navigates Tab B -browser_console({ sinceSeq: 0 }) // gets Tab B's console (separate buffer) -// Agent switches back to Tab A: -browser_switch_tab("targetId_A") -browser_console({ sinceSeq: 5 }) // continues from Tab A's cursor — data preserved! -browser_page_info() // returns Tab A's cached info (not stale from Tab B) - -// Tab A's dialog doesn't leak to Tab B: -// Tab A triggers alert("A") → stored in TabSession["targetId_A"].dialog -// Tab B triggers confirm("B") → stored in TabSession["targetId_B"].dialog -// browser_page_info() on Tab B returns { dialog: { type: "confirm", message: "B" } } -// No overwrite. No silent loss. -``` - -## File Map - -``` -src/cdp/session.ts # MODIFY — TabSession type, Map, ev.sessionId filter, switchTo rework, drain parameterization, cleanup -src/cdp/console-buffer.ts # MODIFY — nextSeq reset on clear() -src/client.ts # MODIFY — per-tab pageCache Map -src/prompt.ts # MODIFY — multi-agent warning -``` - -## Ordering Constraints - -- Slice 1 must come first (foundation — TabSession type, Map, ev.sessionId filter) -- Slice 2 depends on TabSession existing (dialog/dirty-flag/page-cache are TabSession fields) -- Slice 3 depends on TabSession existing (buffers are TabSession fields) -- Slice 4 depends on Slices 1-3 (cleanup reconciles against live TabSession entries) - -## Verification Notes - -From research Precedents & Lessons: -- **`d3d86f9`**: Event consumer must be serialized (already done). Surface crashes via console.warn (already done). -- **`71da8e0`**: Mutex serializes mutations globally, not per-tab. Per-tab data prevents corruption from this design limitation. -- **`20ceaa1`**: Ownership reconciliation pattern must be replicated for TabSession pruning. -- **`a1df2d3`**: Prefix matching hazard grows with owned tab count. TabSession Map uses full targetId keys — no prefix collision risk. - -Verification checks: -- `tsc --noEmit` passes on modified files -- Two tabs: switch A→B→A, console cursor from A's first visit preserves state -- Dialog on tab A is NOT returned by takeDialog() after switching to tab B -- pageInfoDirty from tab B's navigation does NOT invalidate tab A's page cache -- Target.targetDestroyed event removes TabSession from Map -- attachFirstPage reconciliation prunes dead TabSession entries - -## Performance Considerations - -- `Map.get(targetId)` — O(1), negligible (single-digit entries typical) -- Each TabSession's buffers bounded at 500 records — no unbounded growth -- TabSession creation is lazy (on first visit to a tab via switchTo or attachFirstPage) -- No additional CDP round-trips — event routing is purely in-memory - -## Migration Notes - -Not applicable — no persisted schema changes. TabSession state is purely in-memory and reconstructed on each session start. - -## Pattern References - -- `src/cdp/ownership.ts:38-72` — OwnershipRegistry factory: add/remove/has/notify pattern -- `src/cdp/session.ts:41-46` — Current singleton state to extract into TabSession -- `src/cdp/session.ts:112-118` — attachFirstPage ownership reconciliation (replicate for TabSession) -- `src/client.ts:85,168-179` — pageCache TTL pattern (replicate as Map<targetId, Cache>) -- `src/cdp/network-buffer.ts:77-85` — Buffer factory + Map internal pattern - -## Developer Context - -**Q (`src/cdp/session.ts:41-46`, `src/client.ts:85`): Per-tab isolation scope chosen. Phase 1: ev.sessionId filter. Phase 2: per-tab data structures. Phase 3: agent-aware locking deferred.** -A: Approved — proceed with per-tab isolation design. - -**Q (Decomposition): 4 slices: Foundation, Dialog+Dirty+Cache, Buffers+nextSeq, Cleanup+Prompt.** -A: Approved — proceed with slice-by-slice generation. - -## Design History - -- Slice 1: Foundation — approved as generated -- Slice 2: Dialog + dirty flag + page cache — approved as generated -- Slice 3: Buffer isolation + nextSeq fix — approved as generated -- Slice 4: Cleanup + reconciliation + prompt — approved as generated - -## References - -- Research: `thoughts/shared/research/2026-05-07_00-41_multi-agent-safety.md` -- Questions: `thoughts/shared/questions/2026-05-07_00-26_multi-agent-safety.md` -- Rewrite design: `docs/superpowers/specs/2026-05-02-harness-rewrite-design.md` diff --git a/.rpiv/artifacts/plans/2026-05-07_18-15-35_per-tab-isolation.md b/.rpiv/artifacts/plans/2026-05-07_18-15-35_per-tab-isolation.md deleted file mode 100644 index acddf60..0000000 --- a/.rpiv/artifacts/plans/2026-05-07_18-15-35_per-tab-isolation.md +++ /dev/null @@ -1,452 +0,0 @@ ---- -date: 2026-05-07T18:15:35Z -author: amankumarsingh77 -commit: 4052e6e -branch: main -repository: pi-browser-harness -topic: "per-tab-isolation-multi-agent-safety" -tags: [plan, session, isolation, per-tab, buffers, dialog, safety, concurrency] -status: ready -parent: "thoughts/shared/designs/2026-05-07_00-58_per-tab-isolation.md" -last_updated: 2026-05-07T18:15:35Z -last_updated_by: amankumarsingh77 ---- - -# Per-Tab Isolation for Multi-Agent Safety — Implementation Plan - -## Overview - -Implement per-tab data isolation in `pi-browser-harness` so that multiple subagents can use the browser without corrupting each other's state. Replace singleton dialog, page-info-dirty flag, network buffer, and console buffer with per-tab `TabSession` objects stored in a `Map<string, TabSession>`. Add an `ev.sessionId` filter in the CDP event consumer to prevent cross-tab event contamination. Deliverable in 3 sequential phases totaling 4 file modifications. - -Reference design: `thoughts/shared/designs/2026-05-07_00-58_per-tab-isolation.md` - -## Desired End State - -Tab A and Tab B can coexist without cross-contamination: - -- Switching tabs preserves each tab's network/console buffer data (no `clear()` on switch) -- Dialog from tab A is not overwritten by dialog from tab B -- Page info dirty flag is per-tab, not global -- Console `sinceSeq` cursor remains valid across tab switches (no cross-tab data leaks) -- Page info cache is preserved per-tab across switches -- CDP events from unattached sessions are filtered (no cross-contamination) -- Dead tab state is pruned on target destruction and session reconciliation - -## What We're NOT Doing - -- Agent-aware tab locking (requires pi platform agent identity exposure — Phase 3, deferred) -- Observation tool `callOnTarget` refactoring (Phase 3, deferred) -- Per-agent BrowserClient instances -- New CDP transport connections per agent - -## Phase 1: Foundation — TabSession type, Maps, ev.sessionId filter, switchTo rework - -### Overview -Introduces the `TabSession` type, the `Map<string, TabSession>` collection, the `sessionIdToTargetId` reverse lookup, the 1-line `ev.sessionId` guard in `consumeEvents`, and the `switchTo` rework that creates/reuses `TabSession` without clearing buffers. This phase alone prevents cross-tab event contamination and is independently shippable. - -### Changes Required: - -#### 1. TabSession type + infrastructure (`src/cdp/session.ts`) -**File**: `src/cdp/session.ts` -**Changes**: Insert `TabSession` type definition before `createCdpSession`. Inside `createCdpSession`, add `tabs` Map, `sessionIdToTargetId` Map. Add `ev.sessionId` filter in `consumeEvents`. Rework `switchTo` to create/reuse `TabSession`. Update `attachFirstPage` to create initial `TabSession`. Add Map cleanup in `transport.onClose`. - -```typescript -// === Phase 1: Foundation === - -// NEW: Per-tab state type (inserted before createCdpSession, after imports) -type TabSession = { - sessionId: string; - targetId: string; - dialog: DialogInfo | null; - pageInfoDirty: boolean; - networkBuffer: ReturnType<typeof createNetworkBuffer>; - consoleBuffer: ReturnType<typeof createConsoleBuffer>; -}; - -// Inside createCdpSession(), ADD after the singleton declarations: -const tabs = new Map<string, TabSession>(); -const sessionIdToTargetId = new Map<string, string>(); - -// consumeEvents — ADD filter guard as the FIRST statement inside the for-await loop -// (inserted before the existing `if (ev.method === "Page.javascriptDialogOpening")`) -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; - } - // ... all existing event handlers unchanged ... -} - -// switchTo — REPLACE the entire method body: -async switchTo(tid) { - const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null }); - if (!activated.success) return activated; - 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 }; - // 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) { - tabs.set(tid, tab); - await enableDomains(a.sessionId); - } - // Update global pointers to point at the new active tab - sessionId = tab.sessionId; - targetId = tid; - // Update sessionId→targetId reverse lookup for event filtering - sessionIdToTargetId.set(tab.sessionId, tid); - return ok(undefined); -} - -// attachFirstPage — ADD after `sessionId = a.sessionId; targetId = pickTargetId;`: -tabs.set(pickTargetId, { - sessionId: a.sessionId, - targetId: pickTargetId, - dialog: null, - pageInfoDirty: false, - networkBuffer: createNetworkBuffer(), - consoleBuffer: createConsoleBuffer(), -}); -sessionIdToTargetId.set(a.sessionId, pickTargetId); - -// transport.onClose — ADD Map cleanup before restartConsumer(): -tabs.clear(); -sessionIdToTargetId.clear(); -``` - -### Success Criteria: - -#### Automated Verification: -- [x] Type checking passes: `npm run typecheck` -- [x] `grep -c "consumeEvents" src/cdp/session.ts` confirms single consumer -- [x] `grep "ev.sessionId && ev.sessionId !== sessionId" src/cdp/session.ts` confirms filter guard exists - -#### Manual Verification: -- [x] Run `scripts/test-parallel.ts` — parallel mutation tests still pass (mutex unchanged) -- [ ] Open two tabs via `browser_new_tab`, switch between them — no crash, session alive -- [ ] CDP events from non-current tabs are silently dropped (verify: navigate tab A, switch to tab B, network events from tab A don't appear in `browser_network_requests`) - ---- - -## Phase 2: State Migration — Singletons into TabSession + per-tab page cache + nextSeq fix - -### Overview -Moves `dialog`, `pageInfoDirty`, `networkBuffer`, and `consoleBuffer` from singleton variables into `TabSession`. `consumeEvents` routes events to the correct tab's buffer. `takeDialog()`, `drainPageInfoInvalidations()`, `drainNetworkBuffer()`, `drainConsoleBuffer()` all read from the current `TabSession`. `client.ts` `pageCache` becomes `Map<targetId, Cache>`. `console-buffer.ts` `clear()` resets `nextSeq` to 1. - -### Changes Required: - -#### 1. Dialog + dirty flag into TabSession (`src/cdp/session.ts`) -**File**: `src/cdp/session.ts` -**Changes**: Route dialog handler and dirty-flag handler through `TabSession`. Replace `takeDialog()` and `drainPageInfoInvalidations()` to read from current tab. Remove singleton `dialog` and `pageInfoDirty` declarations. - -```typescript -// === Phase 2a: Dialog + dirty flag === - -// consumeEvents — dialog handler: write to TabSession instead of singleton -if (ev.method === "Page.javascriptDialogOpening") { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) continue; - const params = ev.params as Partial<DialogInfo> | undefined; - tab.dialog = { - type: (params?.type as DialogInfo["type"]) ?? "alert", - message: params?.message ?? "", - ...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}), - }; - continue; -} - -// consumeEvents — dirty flag: write to TabSession instead of singleton -if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") { - const tab = targetId ? tabs.get(targetId) : undefined; - if (tab) tab.pageInfoDirty = true; -} - -// takeDialog — read from current TabSession -takeDialog() { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) return null; - const d = tab.dialog; - tab.dialog = null; - return d; -}, - -// drainPageInfoInvalidations — read from current TabSession -drainPageInfoInvalidations() { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) return false; - const dirty = tab.pageInfoDirty; - tab.pageInfoDirty = false; - return dirty; -}, - -// REMOVE the singleton `dialog` and `pageInfoDirty` declarations: -// - let dialog: DialogInfo | null = null; ← REMOVED (now in TabSession) -// - let pageInfoDirty = false; ← REMOVED (now in TabSession) -``` - -#### 2. Buffer routing + drain from TabSession (`src/cdp/session.ts`) -**File**: `src/cdp/session.ts` -**Changes**: Add `resolveTab` helper. Route all network/console event handlers through `resolveTab`. Replace `drainNetworkBuffer()` and `drainConsoleBuffer()` to read from current `TabSession`. Remove singleton `networkBuffer` and `consoleBuffer` declarations. - -```typescript -// === Phase 2b: Buffer routing + drain === - -// consumeEvents — route network/console events to correct TabSession: -// Helper (inserted before the for-await loop): -const resolveTab = (evSessionId?: string): TabSession | undefined => { - const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId; - return tid ? tabs.get(tid) : undefined; -}; - -// Replace all singleton buffer ingest calls with routed calls: -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); -} - -// drainNetworkBuffer — read from current TabSession -drainNetworkBuffer(filter) { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) return { records: [], total: 0, bufferOverflowed: false }; - return tab.networkBuffer.drain(filter); -}, - -// drainConsoleBuffer — read from current TabSession -drainConsoleBuffer(filter) { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) return { records: [], total: 0, bufferOverflowed: false }; - return tab.consoleBuffer.drain(filter); -}, - -// REMOVE the singleton buffer declarations: -// - const networkBuffer = createNetworkBuffer(); ← REMOVED (now in TabSession) -// - const consoleBuffer = createConsoleBuffer(); ← REMOVED (now in TabSession) -``` - -#### 3. nextSeq reset on clear (`src/cdp/console-buffer.ts`) -**File**: `src/cdp/console-buffer.ts` -**Changes**: Replace the `clear()` method body to reset `nextSeq` to 1. - -```typescript -// Replace the clear() method body — reset nextSeq to 1 on clear: - clear() { - records.clear(); - nextSeq = 1; - overflowed = false; - }, -``` - -#### 4. Per-tab page cache (`src/client.ts`) -**File**: `src/client.ts` -**Changes**: Replace single `pageCache` with `Map<targetId, Cache>`. Update `readPageInfo`, `switchTab`, and `stop`. - -```typescript -// REPLACE single pageCache with per-tab Map: -const pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>(); - -// readPageInfo — use per-tab Map instead of singleton: -const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => { - const dirty = session.drainPageInfoInvalidations(); - 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; - if (typeof raw.data !== "string") return err(cdpError("invalid_response", "page info evaluation did not return a string")); - let parsedRaw: unknown; - try { - parsedRaw = JSON.parse(raw.data); - } catch (e) { - return err(cdpError("invalid_response", `page info JSON.parse failed: ${e instanceof Error ? e.message : String(e)}`)); - } - const info = parsePageInfoPayload(parsedRaw); - if (!info.success) return info; - if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() }); - return ok(info.data); -}; - -// switchTab — REMOVE `pageCache = null` (no longer needed — each tab retains its cache): -const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => { - const r = await session.switchTo(targetId); - if (!r.success) return r; - // pageCache = null; ← REMOVED - await session.call("Runtime.evaluate", { - expression: safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`, - }); - return ok(undefined); -}; - -// stop — REMOVE `pageCache = null`, replace with Map.clear(): -const stop = async (): Promise<void> => { - await transport.close(); - pageCaches.clear(); - lastHealth = 0; -}; -``` - -### Success Criteria: - -#### Automated Verification: -- [x] Type checking passes: `npm run typecheck` -- [x] `grep "let dialog" src/cdp/session.ts` returns no matches (singleton removed) -- [x] `grep "let pageInfoDirty" src/cdp/session.ts` returns no matches -- [x] `grep "const networkBuffer" src/cdp/session.ts` returns no matches -- [x] `grep "const consoleBuffer" src/cdp/session.ts` returns no matches -- [x] `grep "nextSeq = 1" src/cdp/console-buffer.ts` confirms the change -- [x] `grep "pageCaches" src/client.ts` confirms Map usage - -#### Manual Verification: -- [ ] Run `scripts/test-parallel.ts` — all tests pass -- [ ] Run `scripts/test-parallel-integration.ts` — all tests pass -- [ ] Open tab A, trigger `browser_console`, note `nextCursor`. Switch to tab B, trigger `browser_console`. Switch back to tab A, call `browser_console({ sinceSeq: <tabA_cursor> })` — returns only new tab A messages (not tab B's) -- [ ] Navigate tab A to a page with an alert. Switch to tab B BEFORE calling `browser_page_info`. Call `browser_page_info` on tab B — returns tab B's page info (NOT tab A's dialog) -- [ ] Tab A navigates (triggering pageInfoDirty). Switch to tab B, call `browser_page_info` twice within 1 second — second call uses tab B's cache (not invalidated by tab A's navigation) - ---- - -## Phase 3: Cleanup + Reconciliation + Prompt - -### Overview -`Target.targetDestroyed` handler removes `TabSession` entries. `attachFirstPage` and `listTabs` prune dead `TabSession` and `pageCaches` entries during reconciliation. `prompt.ts` "Parallel Execution" section gains a multi-agent warning. - -### Changes Required: - -#### 1. TabSession cleanup on target destruction + reconciliation (`src/cdp/session.ts`) -**File**: `src/cdp/session.ts` -**Changes**: Extend `Target.targetDestroyed` handler to remove TabSession entries. Add TabSession pruning loop in `attachFirstPage` reconciliation. - -```typescript -// === Phase 3a: TabSession cleanup === - -// consumeEvents — Target.targetDestroyed: remove TabSession entries -if (ev.method === "Target.targetDestroyed" && ownership) { - const params = ev.params as { targetId?: string } | undefined; - 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); - } - } -} - -// attachFirstPage — ADD TabSession pruning after ownership reconciliation: -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); - } -} -``` - -#### 2. Page cache pruning in listTabs (`src/client.ts`) -**File**: `src/client.ts` -**Changes**: Extend existing `listTabs` reconciliation to prune dead `pageCaches` entries. - -```typescript -// listTabs — ADD page cache pruning after existing ownership reconciliation: -// After: const survivors = owned.filter((id) => live.has(id)); -for (const tid of pageCaches.keys()) { - if (!live.has(tid)) pageCaches.delete(tid); -} -``` - -#### 3. Multi-agent warning in system prompt (`src/prompt.ts`) -**File**: `src/prompt.ts` -**Changes**: Replace the "Parallel Execution" section (lines 63-73) with a version that adds the multi-agent note. - -```typescript -// REPLACE the "Parallel Execution" section (lines 63-73): - -### Parallel Execution - -Observation tools (browser_screenshot, browser_page_info, browser_execute_js, browser_list_tabs, browser_http_get, etc.) can run in parallel with each other and with mutation tools. The harness automatically serializes mutation tools (click, type, scroll, navigate, switch_tab, etc.) so they never race on shared state. When operations are independent, emit them in the same turn for better performance. - -**Examples of safe parallel calls:** -\`\`\` -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. -``` - -### Success Criteria: - -#### Automated Verification: -- [x] Type checking passes: `npm run typecheck` -- [x] `grep "tabs.delete" src/cdp/session.ts` confirms cleanup logic -- [x] `grep "Multi-agent note" src/prompt.ts` confirms prompt update - -#### Manual Verification: -- [ ] Close a tab manually in the browser — `browser_list_tabs` no longer shows it; its TabSession entry is cleaned up -- [ ] Restart the session — `attachFirstPage` reconciliation prunes dead TabSession entries from prior session -- [ ] Spawn two subagents. Subagent A's `browser_page_info` returns its own tab's data after Subagent B switches tabs. Both agents see the multi-agent warning in their system prompts. - ---- - -## Testing Strategy - -### Automated: -- `npm run typecheck` — all phases must pass type checking -- `scripts/test-parallel.ts` — mutex serialization + observation parallelization tests -- `scripts/test-parallel-integration.ts` — serialized mutation + observation + AbortSignal tests - -### Manual Testing Steps: -1. **Tab switch buffer preservation**: Open tab A → navigate → `browser_console` → note `nextCursor` → switch to tab B → `browser_console` → switch back to A → `browser_console({ sinceSeq: cursor })` → ONLY tab A's new messages (no tab B contamination) -2. **Dialog isolation**: Tab A triggers alert → switch to tab B before calling `browser_page_info` → `browser_page_info` on tab B returns tab B's state (NOT tab A's dialog) → switch back to tab A → `browser_page_info` returns tab A's dialog -3. **Page cache isolation**: Tab A `browser_page_info` → tab B navigates (triggers dirty) → tab A `browser_page_info` again — must return stale cached tab A data (not re-fetched tab B data) IF tab A is still current. If tab B switched, tab A data comes from cache on switch-back. -4. **Target destruction**: Close a tab → its TabSession is removed → `browser_list_tabs` no longer shows it -5. **Session restart**: Kill and restart → dead tabs from prior session are pruned in reconciliation - -## Performance Considerations - -- `Map.get(targetId)` — O(1), negligible (single-digit entries typical) -- Each TabSession's buffers bounded at 500 records — no unbounded growth -- TabSession creation is lazy (on first visit to a tab via switchTo or attachFirstPage) -- No additional CDP round-trips — event routing is purely in-memory - -## Migration Notes - -Not applicable — no persisted schema changes. TabSession state is purely in-memory and reconstructed on each session start. - -## References - -- Design: `thoughts/shared/designs/2026-05-07_00-58_per-tab-isolation.md` -- Research: `thoughts/shared/research/2026-05-07_00-41_multi-agent-safety.md` -- Questions source: `thoughts/shared/questions/2026-05-07_00-26_multi-agent-safety.md` diff --git a/.rpiv/artifacts/questions/2026-05-07_00-26_multi-agent-safety.md b/.rpiv/artifacts/questions/2026-05-07_00-26_multi-agent-safety.md deleted file mode 100644 index a14ae12..0000000 --- a/.rpiv/artifacts/questions/2026-05-07_00-26_multi-agent-safety.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -date: 2026-05-07T00:26:00Z -researcher: amankumarsingh77 -git_commit: bd19739 -branch: main -repository: pi-browser-harness -topic: "multi-agent safety in pi-browser-harness: how to make browser tools safe when multiple subagents use them concurrently, preventing shared state corruption, tab switching races, dialog buffer conflicts, and ownership confusion" -tags: [discover, mutex, ownership, dialog, tabs, session, concurrency, safety] -status: complete -last_updated: 2026-05-07 -last_updated_by: amankumarsingh77 ---- - -# Research Questions: Multi-Agent Safety in pi-browser-harness - -## Discovery Summary - -The harness uses three primary safety mechanisms: a lightweight async mutex (`src/util/mutex.ts`, `createMutex()`) that serializes all mutation tools marked `serialized: true` in `src/registry.ts`; an `OwnershipRegistry` (`src/cdp/ownership.ts`) backed by a Set that tracks which tab `targetId`s belong to this session and gates `switchTab`, `closeTab`, and `newTab` in `src/domains/tabs.ts`; and a single-slot dialog buffer in `src/cdp/session.ts` (`createCdpSession`) read via `takeDialog()`. Key files read for depth: `client.ts` (mutationMutex, switchTab, ownership wiring), `session.ts` (dialog buffering, tab switch buffer clearing, pageInfoDirty flag), `tool.ts` (serialized lane acquisition with AbortSignal check), `ownership.ts` (Set-backed registry with change listener), `state.ts` (persistence across sessions), `tabs.ts` (ownership-denied gating), `dialog.ts` (single Page.handleJavaScriptDialog call), `prompt.ts` (concurrency guidance injected into the system prompt), and `index.ts` (lifecycle wiring). The architecture is single-agent by design: one mutex, one sessionId/targetId pair, one dialog slot, one pageCache, and ownership tracked per-session not per-agent — none of these primitives are namespaced or partitioned for concurrent sub-agents. - -## Questions - -1. Trace the full mutation-tool serialization path from invocation to completion: the `registerBrowserTool` function in `src/util/tool.ts` wraps each tool's handler in an `execute` closure that first calls `client.ensureAlive()` (unless `ensureAlive` is false), then checks `def.serialized` and calls `client.mutationMutex().acquire()` — an async FIFO mutex defined by `createMutex` in `src/util/mutex.ts` that chains promises. After acquiring, it checks `signal?.aborted`, calls `def.handler(args, ctx)`, and releases in a `finally` block. Map every tool marked `serialized: true` in `src/registry.ts` (22 of 32 tools). Explain why `waitTool` is NOT serialized while `waitForLoadTool` IS, and whether the mutex provides sufficient isolation when two sub-agents each want to operate on different tabs independently. - -2. Follow the tab ownership lifecycle end-to-end: when `createBrowserClient` in `src/client.ts` creates an `OwnershipRegistry` via `createOwnershipRegistry` in `src/cdp/ownership.ts`, it optionally seeds `ownedTargetIds` from persisted `BrowserState` (restored in `index.ts` `session_start` from `restoreState` in `src/state.ts`). During `attachFirstPage` in `src/cdp/session.ts`, the session reconciles persisted ownership against live targets (dropping dead IDs), prefers re-attaching to an owned tab, or creates a fresh one via `Target.createTarget { newWindow: true }` and registers it with `ownership.add()`. On `Target.targetDestroyed` events, ownership is removed. Trace how `switchTabTool` and `closeTabTool` in `src/domains/tabs.ts` enforce ownership via `client.owns(resolved)` — including hex-prefix resolution logic — and what happens when a sub-agent tries to switch to a tab owned by the session but currently in use by another sub-agent. - -3. Analyze the dialog buffer's single-slot design and its multi-agent failure modes: in `createCdpSession` ( `src/cdp/session.ts` ), the `consumeEvents` async iterator catches `Page.javascriptDialogOpening` and writes to a single `dialog: DialogInfo | null` variable. Critically, `Page.javascriptDialogClosed` is intentionally NOT handled — dialogs stay in the buffer until `takeDialog()` is called. However, `takeDialog()` reads `dialog` then sets it to `null` atomically via assignment, and when the transport closes the dialog is preserved (not cleared in `onClose`). Trace how `client.pageInfo()` in `src/client.ts` calls `session.takeDialog()` first (returning `{ dialog: d }` if present), and how the system prompt in `src/prompt.ts` guides agents to call `browser_handle_dialog`. Identify the race condition where sub-agent A opens a page triggering a dialog, sub-agent B triggers a different dialog on a different tab before A consumes it, and only B's dialog survives. - -4. Map the shared-session architecture and its impact on concurrent sub-agents: `createCdpSession` in `src/cdp/session.ts` maintains a single `sessionId: string | null` and `targetId: string | null` pair. The `switchTo(tid)` method calls `Target.activateTarget`, `Target.attachToTarget`, updates both `sessionId` and `targetId`, sets `pageInfoDirty = true`, and clears both `networkBuffer` and `consoleBuffer`. Trace how `client.switchTab()` in `src/client.ts` additionally nullifies `pageCache` and best-effort marks the tab title. Explain why if sub-agent A is mid-workflow on tab X (expecting `browser_execute_js` results from that tab's DOM) and sub-agent B calls `browser_switch_tab` to tab Y, agent A's subsequent `browser_execute_js` silently targets the wrong page — the `sessionId`/`targetId` pair is global, with no per-agent override. - -5. Trace the page-info caching and invalidation pipeline: `client.readPageInfo()` in `src/client.ts` checks `session.drainPageInfoInvalidations()` (the `pageInfoDirty` boolean in `session.ts`, set on `Page.frameNavigated` and `Page.loadEventFired`), consults a `pageCache` with 1-second TTL, evaluates a JS expression to collect URL/title/viewport/scroll/page-dimensions, parses it through `parsePageInfoPayload()`, and caches the result. Investigate whether a sub-agent switching tabs invalidates another sub-agent's cached page info, and whether the 1-second TTL (`PAGE_INFO_TTL_MS = 1_000`) combined with the shared `pageCache` could cause a sub-agent to receive stale page info from a tab it didn't navigate to. - -6. Examine the state persistence and resumption mechanism across session boundaries: `state.ts` defines `BrowserState` (namespace, remoteBrowserId, ownedTargetIds, harnessWindowTargetId), `persistState()` calls `pi.appendEntry`, and `restoreState()` scans branch entries for the last `browser-harness-state` custom entry. In `index.ts`, `session_start` restores state and wires `onOwnershipChange` to persist on every mutation. Map the full flow: `OwnershipRegistry.onChange` → `onOwnershipChange` callback in `client.ts` → `persistState` → `pi.appendEntry`. Assess whether this persistence model could cause cross-agent confusion if two sub-agents with different browser namespaces share the same pi branch session entries. - -7. Follow the console and network buffer lifecycle across tab switches: in `session.ts`, `createConsoleBuffer()` and `createNetworkBuffer()` (imported from `src/cdp/console-buffer.ts` and `src/cdp/network-buffer.ts`) are cleared wholesale in `switchTo()` — `networkBuffer.clear()` and `consoleBuffer.clear()` — because "the network history of the previous tab is no longer relevant." The `browser_network_requests` domain in `src/domains/network.ts` drains the buffer with filters, and `browser_console` in `src/domains/console.ts` uses a `sinceSeq` cursor for incremental reads. Trace what happens when sub-agent A is iterating console output incrementally (using `sinceSeq` from a prior `nextCursor`) and sub-agent B switches tabs, clearing the buffer and invalidating A's cursor. - -8. Investigate whether the namespace system provides any multi-agent isolation boundary: `index.ts` generates or accepts a `--browser-namespace` flag, stored in `BrowserState.namespace` and passed to `createBrowserClient`. The ownership-denied error message in `src/domains/tabs.ts` (`ownershipDeniedMessage`) references the namespace string, and `client.namespace` is exposed on the `BrowserClient` type. Determine whether two sub-agents running with different `--browser-namespace` values would each get their own `BrowserClient`, mutex, ownership registry, and session — or whether the extension's singleton `client` variable in `index.ts` means all agents share one client regardless of namespace. If the latter, assess what would need to change to support namespace-per-agent client instances. - -9. Analyze the `before_agent_start` hook in `index.ts` and the system prompt injection in `src/prompt.ts` for multi-agent guidance: the hook appends `getBrowserSystemPrompt()` to the system prompt, which includes a "Parallel Execution" section stating that observation tools run in parallel while mutation tools are serialized. Examine whether this prompt guidance is sufficient for a sub-agent to understand that another sub-agent might be concurrently mutating shared state — or whether the prompt assumes single-agent usage and omits crucial warnings about tab switching side effects, dialog slot overwrites, and buffer clearing. - -10. Model the full set of shared mutable state across the entire client and session layer: enumerate every piece of mutable state — `sessionId`, `targetId`, `dialog`, `pageInfoDirty`, `pageCache`, `lastHealth`, `mutationMutex` queue, `networkBuffer` entries, `consoleBuffer` entries, `ownership` Set, `harnessWindow`, `transport` connection — and classify each as either "safe to share across agents" or "requires per-agent partitioning." For each item requiring partitioning, propose the minimal abstraction change (e.g., per-agent dialog queue, per-agent sessionId/targetId stack with restore-on-switch, namespaced buffers with merge semantics). diff --git a/.rpiv/artifacts/research/2026-05-07_00-41_multi-agent-safety.md b/.rpiv/artifacts/research/2026-05-07_00-41_multi-agent-safety.md deleted file mode 100644 index 61d414c..0000000 --- a/.rpiv/artifacts/research/2026-05-07_00-41_multi-agent-safety.md +++ /dev/null @@ -1,223 +0,0 @@ ---- -date: 2026-05-07T00:41:00Z -researcher: amankumarsingh77 -git_commit: bd19739 -branch: main -repository: pi-browser-harness -topic: "Multi-agent safety: making browser tools safe for concurrent subagent usage" -tags: [research, mutex, ownership, dialog, tabs, session, concurrency, safety, subagents, isolation] -status: complete -questions_source: "thoughts/shared/questions/2026-05-07_00-26_multi-agent-safety.md" -last_updated: 2026-05-07 -last_updated_by: amankumarsingh77 ---- - -# Research: Multi-Agent Safety in pi-browser-harness - -## Research Question - -How can the browser harness be made safe for concurrent subagent usage? When multiple subagents are spun up, each using browser tools, they must not corrupt each other's state — tab switching, dialog handling, buffer reads, and page interactions must remain isolated. - -## Summary - -The harness was designed for single-agent use. All 6 safety-critical subsystems — the mutex, session, dialog buffer, page cache, network/console buffers, and ownership registry — are shared singletons with zero per-agent or per-tab partitioning. One agent switching tabs overwrites the global `sessionId`/`targetId`, clears all buffers, nullifies the page cache, and can silently consume or overwrite another agent's dialog. The approach: **phased per-tab isolation** — first an immediate `sessionId` filter on the CDP event consumer to stop cross-tab contamination, then per-tab data structures (dialog queues, page caches, buffer maps, dirty flags), and finally agent-aware tab locking to prevent one agent from switching away from another agent's active tab. - -## Detailed Findings - -### Finding 1: Mutation Mutex Serializes Globally, Not Per-Tab - -The async FIFO mutex (`src/util/mutex.ts:12-23`) serializes the 22 mutation tools marked `serialized: true` in `src/registry.ts:25-49`. Observation tools (11 total, including `browser_execute_js`, `browser_screenshot`, `browser_page_info`, `browser_snapshot`) skip the mutex entirely. - -**The mutex prevents concurrent mutation execution but does NOT provide per-agent isolation.** When Agent B calls `browser_switch_tab` (serialized, `src/domains/tabs.ts:93-128`), the global `sessionId`/`targetId` at `src/cdp/session.ts:41-42` is overwritten. Agent A's subsequent `browser_execute_js` (non-serialized observation, `src/domains/js.ts:55`) silently evaluates on the wrong tab's DOM — the mutex didn't block it because observation tools bypass the mutex. The `browser_execute_js` result contains no indication of which tab it ran on. - -**The `callOnTarget` escape hatch exists but is unused** (`src/cdp/session.ts:150-151`). It accepts an explicit `sessionId`, which would allow per-agent session binding without switching the global session. No observation tool uses it — all call `session.call()` which binds to the global `sessionId` at `src/cdp/session.ts:148`. - -### Finding 2: Shared SessionId/TargetId Is the #1 Corruption Vector - -`src/cdp/session.ts:41-42` holds the single `sessionId`/`targetId` pair. The `switchTo()` method at `src/cdp/session.ts:125-143` performs a destructive overwrite: -- Activates the new target (line 128) -- Attaches a new CDP session (line 130) -- Overwrites `sessionId` and `targetId` (lines 133-134) -- Clears `networkBuffer` and `consoleBuffer` (lines 136-137) -- Sets `pageInfoDirty = true` (line 135) - -`client.switchTab()` at `src/client.ts:179-190` compounds this by nullifying `pageCache` (line 183). There is no snapshot-and-restore mechanism, no per-agent tab stack, and no notification that the tab context changed. - -**Concrete failure scenario**: Agent A is extracting data from tab X with `browser_execute_js`. Agent B calls `browser_switch_tab` to tab Y (serialized, so it waits its turn). After B's switch completes, A's next `browser_execute_js` runs on tab Y's DOM. If tab Y lacks the element A was querying, the result is `undefined` — a silent, undetectable corruption. - -### Finding 3: Dialog Buffer Is a Single Slot With Silent Overwrite - -`src/cdp/session.ts:43` stores one `dialog: DialogInfo | null`. Every `Page.javascriptDialogOpening` event from ANY attached session overwrites it at `src/cdp/session.ts:56-63` — there is no `ev.sessionId` filter, no queue, and no per-tab storage. - -**Concrete failure scenario**: Agent A triggers `alert("Step 1")` on tab X. Before A consumes it via `browser_page_info`, Agent B triggers `confirm("Delete?")` on tab Y. The `dialog` variable is overwritten. When A calls `browser_page_info` (`src/client.ts:177-180`), `session.takeDialog()` returns B's confirm dialog. A dismisses it via `browser_handle_dialog` (`src/domains/dialog.ts:20`), which sends `Page.handleJavaScriptDialog` on the current session (now tab Y). Tab X's alert is never handled — its renderer remains blocked. - -Additionally, `Page.javascriptDialogClosed` events are intentionally ignored at `src/cdp/session.ts:64-67`, meaning stale dialogs can persist indefinitely until `takeDialog()` is called. - -### Finding 4: Network/Console Buffers Cleared on Tab Switch - -Both buffers (`session.ts:46-47`) are cleared in `switchTo()` at `src/cdp/session.ts:136-137`. The console buffer's `nextSeq` counter (`src/cdp/console-buffer.ts:146`) is intentionally NOT reset on clear (lines 259-262), which causes a **cross-tab data leak for stale cursors**. - -**The `nextSeq` claim is inverted**: The comment says "a stale sinceSeq from a previous tab can't accidentally match new records." In reality, because `nextSeq` is NOT reset, new records get seq numbers continuing from where the old tab left off. A stale `sinceSeq=15` will match new records with seq 16+ from the wrong tab. If `nextSeq` WERE reset to 1, stale `sinceSeq=15` would skip all new records (1 <= 15 is true). - -**Concrete failure scenario**: Agent A iterates console output with `sinceSeq: 15` from a prior `nextCursor` on tab X. Agent B switches to tab Y, clearing the buffer. New tab Y logs appear with seq 16, 17. Agent A's next `browser_console({ sinceSeq: 15 })` returns tab Y's output — cross-tab data leakage. Agent A has no indication the data came from the wrong tab. - -### Finding 5: Page Cache Invalidated Across Tabs - -`src/client.ts:85` holds a single `pageCache` with 1-second TTL (`PAGE_INFO_TTL_MS = 1_000` at line 42). It's nullified on every `switchTab` (line 183) and on `Page.frameNavigated`/`Page.loadEventFired` events from ANY session (via shared `pageInfoDirty` flag at `src/cdp/session.ts:45`). - -**While `switchTab → pageCache = null` is correct, Agent A still gets the wrong tab's info on the next `browser_page_info` call** because it reads from the globally-switched session. There is no per-agent "my current tab" context. - -### Finding 6: Ownership Is Per-Session, Not Per-Agent - -The `OwnershipRegistry` (`src/cdp/ownership.ts:26-72`) tracks which tabs belong to the harness session — it protects the USER's tabs, not sub-agent-to-sub-agent isolation. An agent can switch to any owned tab, including one another agent is actively using. - -### Finding 7: Namespace Provides No Isolation - -The extension's `client` variable at `src/index.ts:36` is a singleton. All agents share one `BrowserClient`. The `--browser-namespace` flag is cosmetic — used in error messages (`src/domains/tabs.ts:71-72`) and temp file paths (`src/util/paths.ts:13`), but provides zero multi-agent isolation within a single pi process. - -### Finding 8: System Prompt Assumes Single-Agent Use - -`src/prompt.ts:63-73` states that the harness "automatically serializes mutation tools so they never race on shared state" — this is intra-agent only. The prompt contains no warnings about: -- Tab switching invalidating buffers and cursors -- Dialog slot overwrites -- The need to verify `browser_current_tab` before mutations -- Other agents sharing the same browser - -### Finding 9: Immediate Fix — `ev.sessionId` Filter - -A single change in `consumeEvents` at `src/cdp/session.ts:33` would prevent ALL cross-tab event contamination: - -```typescript -if (ev.sessionId && ev.sessionId !== sessionId && ev.method !== "Target.targetDestroyed") { - continue; -} -``` - -This prevents dialog events from other tabs from overwriting the slot, navigation events from setting `pageInfoDirty` for the wrong tab, and network/console events from polluting buffers. It makes the single-slot design **correct for single-agent use** and eliminates the most egregious cross-tab contamination for multi-agent use. - -## Code References - -- `src/cdp/session.ts:41-42` — Singleton `sessionId`/`targetId` pair, overwritten on every tab switch -- `src/cdp/session.ts:43` — Single `dialog` slot, overwritten by any `Page.javascriptDialogOpening` without `sessionId` filtering -- `src/cdp/session.ts:125-143` — `switchTo()` destructively overwrites session state and clears buffers -- `src/cdp/session.ts:150-151` — `callOnTarget()` escape hatch exists but is unused by observation tools -- `src/cdp/session.ts:45` — Shared `pageInfoDirty` flag set by navigation events from any session -- `src/cdp/session.ts:136-137` — `networkBuffer.clear()` + `consoleBuffer.clear()` on every tab switch -- `src/cdp/console-buffer.ts:256-262` — `clear()` empties records but preserves `nextSeq`, enabling cross-tab data leaks -- `src/client.ts:85` — Single `pageCache` nullified on any `switchTab` -- `src/client.ts:89` — Single `mutationMutex` shared by all agents -- `src/util/tool.ts:127-138` — Serialized tool mutex acquisition with abort guard -- `src/util/mutex.ts:12-23` — 13-line async FIFO mutex with no per-agent awareness -- `src/registry.ts:25-49` — 22 serialized tools vs 11 observation tools -- `src/cdp/ownership.ts:26-72` — Flat ownership Set, no per-agent tab tracking -- `src/index.ts:36` — Singleton `client` variable — one per process, shared by all agents -- `src/domains/tabs.ts:88-118` — switchTabTool ownership enforcement (per-session, not per-agent) -- `src/domains/tabs.ts:167-188` — closeTabTool ownership enforcement -- `src/prompt.ts:63-73` — System prompt "Parallel Execution" assumes single-agent -- `src/state.ts:22-45` — State persistence with no namespace filtering in `restoreState` -- `src/domains/js.ts:55` — `browser_execute_js` uses implicit global `sessionId` via `session.call()` -- `src/domains/console.ts:122-151` — `sinceSeq` cursor pattern with no invalidation on buffer clear -- `src/domains/network.ts:119-154` — `browser_network_requests` body fetch races against tab switches - -## Integration Points - -### Inbound References -- `src/index.ts:99-119` — `session_start` creates the singleton `BrowserClient`, all agents share it -- `src/index.ts:152-161` — `before_agent_start` injects system prompt, fires for every agent turn with no per-agent customization -- `src/registry.ts:64-66` — `registerAllTools` binds all tools to the singleton `client` via closure - -### Outbound Dependencies -- CDP WebSocket (`src/cdp/transport.ts:25`) — single connection to Chrome, all sessions multiplexed over it -- Chrome DevTools Protocol — `Target.attachToTarget`, `Target.activateTarget`, `Target.createTarget`, `Target.closeTarget` -- pi Extension API — `pi.registerTool`, `pi.on("before_agent_start")`, `pi.on("session_start")`, `pi.appendEntry` - -### Infrastructure Wiring -- `src/cdp/transport.ts:68` — Single event queue consumed by exactly one `for await` loop in `session.ts` -- `src/cdp/session.ts:57-83` — Single `consumeEvents` loop routes CDP events to all buffers -- `src/client.ts:88-96` — `onOwnershipChange` → `persistState` → `pi.appendEntry` chain - -## Architecture Insights - -1. **The entire extension is designed around a single-agent-per-process model.** Every subsystem (transport, session, ownership, buffers, cache, mutex) assumes exclusive access. There is no concept of multiple independent consumers. - -2. **The mutex provides execution serialization but not semantic isolation.** It guarantees two mutations don't run simultaneously, but cannot prevent Agent B from switching tabs between Agent A's observation and its next mutation. - -3. **`callOnTarget` proves the transport layer supports per-target sessions.** The infrastructure is capable of per-agent isolation — the tool layer simply doesn't use it. - -4. **The namespace system is identity-only, not isolation.** It's a label used in error messages and file paths. To become an isolation boundary, it would need to key into separate `BrowserClient` instances. - -5. **Event filtering by `sessionId` is the single most impactful change.** It would fix dialog overwrites, dirty flag theft, buffer pollution, and stale event processing — all from one guard in `consumeEvents`. - -6. **The console `nextSeq` non-reset is a design inversion.** The comment claims it prevents stale cursor matches — the exact opposite is true. Resetting `nextSeq` on clear would prevent cross-tab data leaks. - -## Precedents & Lessons - -4 similar past changes analyzed. - -### Precedent: Parallel tool execution with automatic mutation serialization -**Commit(s)**: `71da8e0` — "feat: parallel tool execution with automatic mutation serialization" (2026-05-04) -**Blast radius**: 8 files across 5 layers - - `src/util/mutex.ts` — new ~25 LOC async FIFO mutex - - `src/util/tool.ts` — serialized lane acquisition + AbortSignal guard - - `src/registry.ts` — 22 of 32 tools marked `serialized: true` - - `src/client.ts` — `mutationMutex()` exposed on `BrowserClient` - -**Follow-up fixes**: -- `f73941c` — Screenshot path rendering crash from parallel invocations - -**Takeaway**: The mutex prevents mutation-on-mutation races but creates a global bottleneck — one sub-agent's slow `waitForLoad` blocks ALL other agents' mutations, even on different tabs. - -### Precedent: Tab ownership — isolate harness tabs from user's browsing -**Commit(s)**: `20ceaa1` — "feat: tab ownership" (2026-05-05) -**Blast radius**: 8 files across 5 layers - - `src/cdp/ownership.ts` — new 72-line OwnershipRegistry - - `src/cdp/session.ts` — attachFirstPage reconciliation, targetDestroyed reaping - - `src/domains/tabs.ts` — ownership gating on switch/close - -**Takeaway**: Ownership protects the USER's tabs but does NOT protect sub-agents from each other. Two sub-agents share one `sessionId`/`targetId` — switching tabs is a global side effect. - -### Precedent: CDP session with single-slot dialog buffer -**Commit(s)**: `d3d86f9` — "fix(cdp): serialize event consumer; persist dialog until taken" (2026-05-02) -**Blast radius**: `src/cdp/session.ts` - -**Follow-up fixes**: -- `c9d1605` — Surface event-consumer crashes via `console.warn()` - -**Takeaway**: The dialog's single-slot buffer was corrected for fast-dismiss races but remains unguarded against multi-session overwrites. - -### Precedent: Audit fixes for list_tabs/switch_tab prefix matching -**Commit(s)**: `a1df2d3` — "release: v0.3.1 — audit fixes" (2026-05-02) -**Blast radius**: 9 files across 4 layers - -**Takeaway**: Prefix resolution for targetIds becomes more ambiguous as owned-tab count increases in multi-agent scenarios. - -### Composite Lessons -- ALL safety primitives are single-agent by design — `71da8e0`, `20ceaa1`, `d3d86f9` -- The mutex serializes globally, not per-tab — `71da8e0` -- Tab switching is a silent cross-agent side effect — `20ceaa1` -- Dialog buffer is a single slot with silent overwrite — `d3d86f9` -- Buffer clearing on tab switch invalidates per-agent cursors — `d3d86f9` -- Prompt and SKILL docs assume single-agent usage — `71da8e0` - -## Historical Context (from thoughts/) -- `thoughts/shared/questions/2026-05-07_00-26_multi-agent-safety.md` — 10 trace-quality questions covering every safety subsystem -- `docs/superpowers/specs/2026-05-02-harness-rewrite-design.md` — v0.3.0 rewrite design; §2.2 enumerates 13 predictability bugs fixed by the module split -- `docs/RESEARCH.md` — Competitive analysis showing parallel execution + auto-serialization as a unique differentiator -- `CHANGELOG.md` — v0.3.2 documents parallel-safe boundary; v0.4.0 adds snapshot-first patterns - -## Developer Context - -**Q (`src/cdp/session.ts:41-46`): All safety primitives (mutex, ownership, session, buffers, dialog, page cache) are shared singletons with zero per-agent partitioning. Which isolation scope should we target?** -A: Per-tab isolation (Recommended) — Add per-tab data structures: per-tab dialog queue, per-tab page cache, per-tab buffer maps, per-tab dirty flags. Agents still share one client but each tab has independent state. Minimal change preventing data corruption without connection overhead. - -**Q (`src/cdp/session.ts:33`): The per-tab isolation can be delivered in phases. Which delivery cadence?** -A: Quick-win first (Recommended) — Phase 1: `ev.sessionId` filter in `consumeEvents` (1-line fix). Phase 2: per-tab data structures. Phase 3: agent-aware tab locking. - -## Related Research -- Questions source: `thoughts/shared/questions/2026-05-07_00-26_multi-agent-safety.md` - -## Open Questions -1. **Agent identity exposure**: The pi platform currently does NOT expose the calling agent's ID to tool handlers. Phase 3 (agent-aware tab locking) requires identifying which agent initiated a tool call. Does the `ExtensionContext` carry agent identity, or would this require a pi platform change? -2. **Observation tool isolation**: Even after per-tab data structures, observation tools (`browser_execute_js`, `browser_screenshot`) will still read from the globally-switched session. Should they be changed to use `callOnTarget` with a per-agent cached sessionId? -3. **Tab switching protocol**: Should agents be required to explicitly "check out" and "release" tabs? Or should the harness auto-detect which agent was last on a tab and restore context before executing tools? -4. **Namespace-per-client in separate processes**: When two separate pi processes (e.g., parent + subagent) both load the harness, each creates its own `BrowserClient` — they're naturally isolated. Should we document this pattern as the recommended multi-agent approach? |
