Multi-tab orchestration, and the loop relocation that had to happen first.
What shipped in v1.4: an agent that opens, groups, and switches between multiple tabs inside its own Chrome tab group, a ReAct loop relocated out of the page and into the service worker to make that possible, and a write-ahead journal plus an automated eviction canary that proves a resumed turn never repeats a dispatched action.
Auto Browser team · July 2026 · 8-minute read
Our last release post walked through v1.1: the perception rewrite, the four-provider abstraction, the eval harness that finally put a number on whether a change helped. A couple of versions have shipped quietly since then. This is the v1.4 post, because the headline change doesn’t fit in a changelog line.
The agent can now open, group, and drive more than one tab inside a single task, instead of being confined to the tab it started on. That capability didn’t come from a new tool bolted onto the existing loop. It came from moving the loop itself, out of the page and into the service worker, because a loop that lives inside one tab’s content script has no way to also mind a second one.
Why one tab was a hard ceiling
The agent’s ReAct loop, perceive, decide, act, has always run inside the content script injected into the page, in Chrome’s isolated JS world. That world exists because of the tab it was injected into, and it has no presence anywhere else. A content script can’t be asked to also drive a second tab, because it was never injected into it.
Single-tab tasks never exposed this limit, because they never needed the loop to be anywhere it wasn’t already. A comparison task does. So does “watch the tracking page while the checkout finishes loading.” The moment a task needs the agent to hold state across more than one page at once, a tab-resident loop isn’t a limited version of that feature. It’s structurally the wrong place for the decision-maker to sit.
Moving the loop into the service worker
Of the extension’s three components (background service worker, content script, side panel), only one isn’t tied to a specific tab: the service worker. So that’s where the loop’s brain moved. History, working memory, the post-action validator, recovery, the LLM call itself, all of it now lives in src/orchestration/, on the service-worker side.
Each content script got demoted to a thin RPC server answering a small fixed set of requests from the worker: PERCEIVE, RESOLVE_UID, DISCOVER_TOOLS, EXECUTE_WEBMCP, INVALIDATE_SNAPSHOT, PAGE_STATE, PAGE_SIGNATURE. Each one wraps perception and execution code that already existed; only the ownership moved. The refmap, the uid-to-element mapping take_snapshot hands out, stays in the page, because a uid only means something where the DOM it points into actually lives.
A session now tracks two tabs at once, not one: an anchor, the tab that owns the side panel and the status you watch, and a focus, whichever tab the agent is actively perceiving and acting on right now. Every page-targeted RPC re-reads getFocus() on every single call instead of caching it once per turn, so a switch_focus mid-turn retargets the very next perceive without any special-casing downstream.
None of this shipped as a cutover. The foundational module, tab-group.js, the piece that decides which tabs the agent is even allowed to touch, landed test-first: 35 tests, full suite at 1549, and not wired into anything yet. The loop relocation and the tab-focused wiring followed, all behind a swLoop flag defaulting to off. Production was provably unaffected throughout; single-tab behaviour was, and still is, exactly the pre-existing path whenever the flag is off.
Tab groups as the trust boundary
An agent that can open tabs needs a hard answer to “which tabs am I allowed to touch,” and a hard answer means something the background page checks synchronously before every dispatch, not a line in a system prompt the model is free to ignore.
That’s tab-group.js. Each task runs inside a real, visible Chrome tab group, titled “Auto Browser,” colour-assigned round robin from eight named Chrome tab-group hues (grey is deliberately excluded; it reads as a disabled group, not an active one). While a turn is in flight, the group title gets a ⌛ prefix, so you can tell at a glance that the agent is working without opening the side panel. isMember(sessionId, tabId) gates every dispatch at the background action chokepoint, synchronously and authoritatively: the agent cannot drive, navigate, or close a tab that isn’t in its own group. In-memory maps are the source of truth; chrome.storage is only a durability mirror, so a session can re-adopt its group after the service worker restarts.
Four tools expose this to the model, all new, all routed straight to the service worker, because a content script has no way to reach chrome.tabs or chrome.tabGroups on its own:
open_tab(url)opens a tab and adds it to the group, but doesn’t focus it. The agent has to callswitch_focusexplicitly before it can act on what it just opened. The URL runs through the same safety policy as everything else: blocklisted or non-http(s) URLs are refused outright, and a domain outside the user’s allowlist is refused too, unless it gets approved first.list_tabs()returns every member tab, tabId, url, title, and which one is currently focused. Members only. The tool’s own description says it plainly: “you can only see and act on tabs you opened.”switch_focus(tab_id)moves the focus. Every subsequent perceive, snapshot, and action call targets the new tab until it moves again.close_tab(tab_id)closes a member tab, but not the anchor. Ending the task closes it withtask_complete, not by pulling the tab out from under the panel that’s showing it.
Opening a tab to a domain your policy marks “ask” used to just refuse outright and silently, because there was no UI wired up to ask. It now prompts, reusing the same Allow / Don’t-allow chip the extension already uses for navigation approvals. And tearing a group down only ever ungroups; it never closes your tabs. When a task finishes, the grouping dissolves and your tabs go back to being ordinary tabs. Send another message and a fresh group forms around whatever’s still open.
Shipped dark, then finished
The foundational pieces, the tab-group authority, the loop relocation, the tab-focused wiring, shipped dark behind the flag first. The remaining work closed out the seven pieces that had been deliberately deferred: a readiness-retry transport that waits for a fresh content script to come up before routing to it, group dissolution and re-formation around task_complete, the working-indicator broadcast, re-adopting a group after a service-worker restart, the interactive open_tab approval described above, a group-scoped sweep that stops a blocklisted background tab from loading unattended, and the last visible piece, a tab strip in the side panel.
The strip renders one pill per member tab, marking which one is the anchor and which one currently has focus, and it’s hidden outright when a group only has one member, so the common case looks exactly like it always did. Every failure path, a rejected group query, a member tab that’s already closed, degrades to quietly hiding that row. A tab-strip glitch touching the chat itself was a deliberate non-goal.
With those seven pieces done, the flag flipped from opt-in to opt-out: swLoop now defaults to true, and {swLoop: false} is the kill switch if you want the old behaviour back. Two cases still run the original in-page loop on purpose, not by omission: incognito tabs, which get no cross-tab storage trace at all, and any turn carrying an attachment, which still rides the direct content-script path it always has.
The eviction problem multi-tab made unavoidable
Service workers in MV3 idle out fast, and we’d already built a keepalive for that once before, for the single-tab case (that’s in the v1.1 post). Multi-tab makes the same problem worse in a way that isn’t optional to handle: a task spanning several tabs runs longer per turn by construction, more navigations, more waiting on pages to settle, and the entire decision loop now lives in the one process Chrome is most willing to kill. Eviction mid-turn stops being a rare edge case and becomes something to expect on any task with real length to it.
The dangerous version of that problem is specific: if the worker vanishes right after dispatching a mutating action but before it records whether that action succeeded, what does the resumed turn do with it? Firing it again is how a checkout gets submitted twice.
The fix is a write-ahead action journal. Every mutating dispatch is bracketed by a journal.begin / journal.complete pair before it’s sent, and a durable activeTurn marker on the session record survives the worker dying. When the worker wakes back up, a sweep checks for an interrupted turn (10-minute TTL, two resume attempts, then a named give-up notice instead of silence) and resumes it by re-perceiving from the durable transcript, seeded with a reminder that tells the model it’s picking a turn back up rather than starting fresh. The one rule the whole mechanism exists to enforce: it never re-fires an action that was dispatched but not yet confirmed. An interrupted approval gets the same care. If open_tab was waiting on an “ask” prompt when the worker died, you can still answer “Allow” after it wakes up; a one-shot grant lets the resumed turn’s identical re-request through without asking twice. “Don’t allow” keeps the same fail-closed expiry it always had.
We didn’t want to ship that on the strength of an argument, so there’s a canary that proves it instead of asserting it. npm run eval:canary:evict drives the real service-worker loop against a scriptable mock LLM, force-evicts the worker mid-turn with a raw CDP Target.closeTarget call (the ServiceWorker CDP domain isn’t exposed at the browser endpoint, and an attached debugger otherwise blocks Chrome’s own idle eviction, so this is the only clean programmatic way to kill it on demand), wakes it back up, and checks how many times the dispatched action actually landed on the mock server. The assertion is one number: exactly one hit. Not zero, not two.
The review pass on that canary caught two real bugs in the canary itself. First, the turn-completion check was scanning a tab’s entire persisted history instead of only what happened after the run started, so on a reused tab a second run could report the first run’s leftover result as its own before it had done anything. Second, a rejected approval-send during the eviction window, the worker mid-restart, refusing the connection, was landing in a .catch() that resolved to a sentinel value instead of throwing, so the failure was silently and permanently swallowed in exactly the window the whole canary exists to exercise. Both got fixed, both got re-verified against a live Chromium run reproducing the scenario. The suite that caught it: 2000 green, 15 of them new.
What this changes
Multi-tab is a loop-anatomy change, not a feature bolted onto the side of the old one. Loop anatomy is one of the five lenses from our field guide to evaluating a browser agent: what happens in a decision turn, what the agent carries between turns, and now, for us, whether the thing making the decision is even scoped to a single tab. It isn’t anymore. One decision-maker in the service worker, a set of thin page-side executors, and a focus that can move between them mid-task.
Practically: a task that spans several sources, or needs one tab kept open while you act on another, is one conversation now instead of several single-tab runs stitched together by hand. The kill switch stays available in settings for anyone who wants the old single-tab loop back.
Try it
Auto Browser 1.4 is live now, install from the Chrome Web Store. If you run a task across several tabs and the agent does something you didn’t expect, the tab strip in the side panel will show you exactly which tab it thinks it’s driving, which is usually the fastest way to tell us what went wrong. Our mail is open, same as always.
Previous posts in this series: the perception rewrite and the eval · how to evaluate a browser agent.