aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--.rpiv/artifacts/designs/2026-05-07_00-58_per-tab-isolation.md507
-rw-r--r--.rpiv/artifacts/plans/2026-05-07_18-15-35_per-tab-isolation.md452
-rw-r--r--.rpiv/artifacts/questions/2026-05-07_00-26_multi-agent-safety.md40
-rw-r--r--.rpiv/artifacts/research/2026-05-07_00-41_multi-agent-safety.md223
5 files changed, 2 insertions, 1223 deletions
diff --git a/.gitignore b/.gitignore
index 320c8ad..0c71f0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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?