← All posts
How I Stopped Claude From Clobbering My Browser Tabs
ai-tools

How I Stopped Claude From Clobbering My Browser Tabs

Two Claude chats, one Chrome profile, and a tab that vanished mid-task. Here's the small Chrome extension I built to give Claude a deterministic ownership signal — and why opening tabs in the background turned out to matter more than I expected.

I was halfway through a sentence in a LinkedIn DM when the tab navigated itself to a Vercel dashboard. A Claude session I'd kicked off ten minutes earlier in another window had decided that the LinkedIn tab was its tab, and used it to go check a deploy.

Nothing was lost — LinkedIn keeps DM drafts. But the trust was. From that point I couldn't leave Claude unattended without wondering which of my open tabs would still be mine when I came back.

A few weeks of patches later, I have a small Chrome extension that solves it. This is what was actually broken, what I tried first that didn't work, and what the final design looks like.

1. The setup that breaks

I run multiple Claude Code chats against the same project. Each chat has its own Playwright MCP server, but they all share one Chrome profile because Chrome's SingletonLock won't let two Chrome processes touch the same --user-data-dir. Add the human (me, browsing in the same Chrome) and now three actors are sharing one tab strip.

Playwright tracks tabs in an internal _tabs array, in page-creation order. Every MCP subscribes to the same BrowserContext lifecycle events. So when any actor closes a tab, every MCP's _tabs array splices and indices shift. A Claude that was about to call browser_tabs select 4 now selects something it didn't intend.

The problem isn't the indices, though. The problem is that Playwright has no signal for who created the tab. From browser_tabs list, every entry looks identical:

0: LinkedIn — feed
1: Google Search Console — Performance
2: GitHub — danielcowx/danielcowx.com
3: Vercel — Deployments

Which of those did Claude open earlier? Which did I open just now? Without an answer, Claude has to guess (fragile) or probe every single tab (slow, and it changes the active tab in the process). Sometimes it guesses wrong and browser_navigate's a tab I was mid-task on to a completely different URL. Tab clobber.

2. The naive fixes that didn't quite work

I tried four things before landing on the extension. Each one fixed part of the problem and surfaced a new edge case.

Tag tabs with window.name. Set window.name = 'claude-${session_tag}' on every tab Claude opens, then probe later. Solid pattern, but window.name resets on some cross-origin navigations (Firefox always, Chrome sometimes), and a few sites legitimately overwrite it. You get false negatives just often enough to not trust it standalone.

At-most-one-tab-per-domain. "If Claude needs LinkedIn and a LinkedIn tab exists, reuse it." Works until the user opens their own LinkedIn tab in parallel — now there are two LinkedIn tabs and Claude doesn't know which is its.

Pin Claude's tabs. Visually distinct, survives session restore. But I have my own pinned tabs, and Claude pinning more of them clutters the pin row. Also, Chrome forbids pinned tabs in tab groups, which became an issue later.

Lowest-index disambiguation. "If there are two LinkedIn tabs, the one Claude opened is the one with the lower index, because it's older." True until Chrome's session-restore reorders everything on relaunch, at which point the assumption silently flips and you start clobbering the wrong tab.

The thread running through all of these: Claude was inferring ownership from heuristics. The reliable solution had to make ownership explicit and queryable.

3. What ended up working

A small MV3 Chrome extension (~200 lines of JS, three files) does two things:

  1. Maintains a yellow tab group called "Claude". Anything in the group is Claude's. Everything else is mine. The group is visible at a glance — I can literally see the boundary in the tab strip.
  2. Exposes a postMessage bridge so Claude can ask "what do you own?" and "open this URL for me without stealing focus."

Three components do all of the work:

File Role
manifest.json MV3 manifest. Declares tabs, tabGroups, storage permissions; injects the content script into <all_urls> at document_start.
background.js Service worker. Listens for chrome.runtime.onMessage and dispatches against chrome.tabs and chrome.tabGroups.
content.js Content script. Reads window.name at document_start and auto-claims the tab if it matches claude-*. Relays window.postMessage queries from page → service worker.

There are three ways a tab gets into the Claude group:

  1. window.name handshake. Claude sets window.name = 'claude-${session_tag}' before navigating a brand-new tab. The content script reads window.name on the next page load (at document_start), tells the service worker, and the SW moves the tab into the group. This is how Playwright-created tabs get claimed automatically.
  2. open-tab bridge command. Claude asks the extension to open a URL. The SW calls chrome.tabs.create({ active: false, … }) and claims the new tab in one step. This is the preferred path — it doesn't steal keyboard focus.
  3. move-to-claude-group bridge command. For tabs Claude needs to claim retroactively (most often chrome:// pages, where content scripts can't run).

My tabs never trigger any of these paths. They stay outside the group, untouched.

4. The bridge protocol

The bridge is a window.postMessage envelope, nonce-matched so multiple in-flight queries don't cross wires:

function bridgeQuery(payload) {
  return new Promise((resolve) => {
    const nonce = Math.random().toString(36).slice(2);
    const handler = (e) => {
      if (e.source !== window) return;
      if (e.data?.type !== 'claude-tab-bridge-response') return;
      if (e.data.nonce !== nonce) return;
      window.removeEventListener('message', handler);
      resolve(e.data);
    };
    window.addEventListener('message', handler);
    window.postMessage(
      { type: 'claude-tab-bridge-query', nonce, payload },
      '*'
    );
    setTimeout(() => resolve({ error: 'timeout' }), 3000);
  });
}

Claude calls this via browser_evaluate from any tab that has the content script injected (any http/https page). Five commands cover everything:

Payload Effect
{ type: 'get-tab-state' } Snapshot of every tab + every group, including an ownedByClaude boolean per tab.
{ type: 'open-tab', url } Creates a tab in the background (active: false) and auto-claims it.
{ type: 'move-to-claude-group', tabId } Claims an existing tab.
{ type: 'release-tab', tabId } Hands a tab back to me.
{ type: 'reload-self' } Triggers chrome.runtime.reload() so I don't have to navigate to chrome://extensions/ to pick up edits.

Why postMessage and not direct DOM access from the content script? Sandboxes. Content scripts and page scripts share the same DOM but live in isolated JavaScript contexts — they can't see each other's variables. postMessage is the supported way to cross that boundary, and it works without leaking the extension's API surface to the page itself.

5. The unexpected hero: opening tabs in the background

I built the ownership signal first because that was the bug I noticed. The open-tab bridge command was almost an afterthought — while we're in here, let's also fix the focus theft.

Focus theft turned out to matter more than ownership.

browser_tabs new always activates the tab it creates, which means every time Claude needed a new tab, it yanked me out of whatever I was typing. Maybe twenty times a day. Each time, three or four characters into the wrong text input.

chrome.tabs.create({ active: false, … }) doesn't do that. The new tab appears in the strip, lands in the Claude group, and starts loading — and nothing about my current tab changes. Once that landed, I stopped flinching every time Claude announced "opening a new tab."

Side note for anyone building similar tooling: this is the kind of thing that doesn't show up in a feature list, doesn't have a clean before/after metric, and disappears completely from the daily experience the moment it's fixed. It's the highest-leverage UX work I did all month, and I almost didn't do it.

6. The unsolved edges

Three things this design doesn't handle well, and what I do about them:

chrome:// pages don't run content scripts. That's a Chrome platform constraint, not a bug in the extension. The move-to-claude-group command handles it — Claude can claim a chrome://extensions/ tab from the SW side using the tab id obtained via get-tab-state from a regular page. Slightly awkward, fully workable.

Cmd+Q kills everything. Chrome runs as a grandchild of Claude Desktop (Claude Desktop → playwright-mcp → Chrome). When I quit Claude Desktop, the cascade kills Chrome too. Every tag is lost. Recovery is automatic — the next session starts fresh and tags new tabs as they come — but any in-flight ownership state is gone. I considered persisting the group title across launches via chrome.storage, but Chrome's session-restore handles it for me as long as the profile's restore_on_startup setting is set to "Continue where you left off."

Profile-persisted manual install is the stable path. I tried loading the extension automatically via --load-extension in Playwright's launchOptions.args. Chrome 147 silently refused to honor it, and worse, it appeared to corrupt subsequent manual Load Unpacked attempts in the same session. So the install protocol is: load it once via chrome://extensions/ → Developer mode → Load Unpacked, and Chrome persists the install in .playwright-profile/Default/Extensions/ across every subsequent launch. Two-minute manual step per project profile, then it's invisible.

7. What it bought me

Three things, in descending order of how often I notice:

  1. Background tab opens. No more flinch reflex when Claude announces it's opening a new tab.
  2. A visible ownership boundary. I can see, at a glance, which tabs are Claude's. The yellow group color in Chrome's tab strip is genuinely effective — I notice it the way I'd notice a different-colored folder in a file manager.
  3. Zero clobber events since deployment. Multi-chat handoffs (one Claude finishing, another picking up Chrome) are safe. I can drag tabs around freely without breaking anything, because _tabs ordering is page-creation order, not visual order.

8. Where this generalizes

The specific extension is for Claude + Playwright MCP, but the pattern is broader: any human-plus-AI shared interface where the AI takes actions that look identical to the human's needs an explicit ownership signal. Tab strips are one example. Editor tabs, file system folders, notification trays, conversation threads — the same problem appears everywhere a single workspace gets shared between an autonomous agent and a person.

The deeper lesson is that ambient ownership signals beat probing every time. Probe-and-guess works for one or two artifacts. The moment you have a strip of indistinguishable items, you need the items themselves to carry the answer.

If you want to look at the code, it's open at github.com/danielcowx/dotfiles under claude/chrome-extensions/tab-ownership/. About 200 lines of JavaScript, MIT-equivalent (it's a personal dotfiles repo), and it scales from one Claude chat to as many as Chrome can keep alive without falling over.

Mine's currently at six.