The problem
Several AI agents (Claude Code sessions, Playwright scripts, chrome-devtools-mcp, his own browser-agent) wanted to use one real browser profile, the one with the logins. The Chrome DevTools Protocol has no notion of ownership (README.md), so every agent could see every tab, grab the human's tab, steal window focus, or quit the whole browser. Running separate browsers loses the logins.
Constraints
- No changes to clients. Whatever already connected to port 9222 had to keep working: Playwright's
connectOverCDP, Puppeteer, raw websockets, and legacyGET /jsoncallers. - The human's own browser, so agent tabs live as background tabs in the human's window.
- Chromium throttles hidden tabs. The launch script sets the background-timer flags, and the README notes that
requestAnimationFramestill never fires in a hidden tab. - macOS
open -aand AppleScriptactivatebypass CDP entirely, so a CDP proxy can't intercept them.
What he built
proxy.py (about 960 lines) takes over port 9222 and moves the real browser behind it to 9229.
Registrymaps each tab to an owner.create()registers ownership before returning, because Chrome announces new targets to auto-attached clients immediately (comment at proxy.py:210-212). Owned tab ids are saved so orphans are closed after a restart._browser_session()gives each connection a filtered view:Target.getTargets, attach events, sessions and child frames only show the caller's own tabs. It holds thecreateTargetreply untilattachedToTargethas been delivered, because Playwright and Puppeteer expect that order and the proxy now spans two sockets.FOCUS_STEALERS(Page.bringToFront,Target.activateTarget) andBROWSER_KILLERS(Browser.close,Browser.crash,Browser.setWindowBounds) are answered with success and never forwarded.front_door()is a raw asyncio server on 9222, because thewebsocketslibrary rejects any HTTP method but GET before user code runs, and newer clients sendPUT /json/new.Stageis a queue for the rare case an agent needs to show the human a page: one at a time, with a busy response (HTTP 409) and a dwell time. A sample Claude CodePreToolUsehook redirectsopen -acalls to it.
The README states the ownership rule is "deliberately dumb": only tabs the proxy created are visible. Focus detection was rejected because "a heuristic that is wrong once is worse than a rule that is boring."
Upstream credit: Chrome DevTools Protocol and Chromium, the Python websockets library. Linux support was verified by an outside contributor (@Hronom).
Hard bugs
- Puppeteer quit the browser (84248ea).
browser.close()sendsBrowser.close, which through raw CDP closed every window and tab. Now swallowed. The same commit addedfront_doorand fixed a hang in Playwright'snew_context. - Agents closed each other's pages (b06d599). The stage kept a single tab and closed the previous one regardless of owner. It now tracks one tab per owner.
- Window thrown back and forth (01d16b9). Two Claude Code sessions ran
open -aminutes apart and fought over the window while the proxy log was clean. The cause was macOS calls that never touch CDP; the fix was the stage plus the hook. - Claim corrected by measurement (5fb3028). The docs said the background flags kept hidden tabs painting. A test showed 0 animation frames in a hidden tab while timers kept running, and the docs were corrected.
Results (from the repo)
- Stage behavior, measured (README.md): agent A shown after 0.7 s; agent B refused in 0.0 s while A held the stage; agent C waited and was shown at 7.9 s.
- Hidden tab after 4 s: 0 animation frames while a 100 ms timer kept ticking, on Ubuntu/Chrome and macOS/Brave (README.md).
- Verified by hand on macOS, Linux (outside contributor) and Windows 11. No automated tests; the README says so.
What it shows
He debugs at the protocol level, chooses a simple rule that can be explained over a clever one that can be wrong, and corrects his own docs when a measurement disagrees with them.