diff --git a/extension/README.md b/extension/README.md
index 4073eba11..c3977237f 100644
--- a/extension/README.md
+++ b/extension/README.md
@@ -24,3 +24,15 @@ Suggested Chrome Web Store justification for `downloads`:
> so agents can wait for downloads triggered during an automation workflow. The
> command filters by a user-provided filename or URL pattern and timeout. We do
> not modify, redirect, or persist user download history.
+
+## Browser Tab Groups
+
+OpenCLI groups browser-session tabs under `OpenCLI Browser` by default. To keep
+those tabs ungrouped, open the extension popup and turn off **Group browser
+tabs**. The preference is stored in the Chrome profile and takes effect for
+existing live OpenCLI groups as well as future browser sessions.
+
+Chrome does not expose saved tab groups through the extension API. Turning the
+setting off prevents new saved `OpenCLI Browser` entries, but previously saved
+`OpenCLI Browser` entries and legacy `OpenCLI Adapter` entries must be removed
+once from Chrome's saved tab groups bar.
diff --git a/extension/dist/background.js b/extension/dist/background.js
index c6306aca8..dca4dca5b 100644
--- a/extension/dist/background.js
+++ b/extension/dist/background.js
@@ -961,6 +961,7 @@ const IDLE_TIMEOUT_DEFAULT = 3e4;
const IDLE_TIMEOUT_INTERACTIVE = 6e5;
const IDLE_TIMEOUT_NONE = -1;
const REGISTRY_KEY = "opencli_target_lease_registry_v2";
+const BROWSER_TAB_GROUPS_ENABLED_KEY = "opencli_browser_tab_groups_enabled";
const LEASE_IDLE_ALARM_PREFIX = "opencli:lease-idle:";
const CONTAINER_TAB_GROUP_TITLE = {
interactive: "OpenCLI Browser",
@@ -975,6 +976,17 @@ const ownedContainers = {
automation: { windowId: null, groupId: null, promise: null, groupPromise: null }
};
const interactiveGroupLedger = /* @__PURE__ */ new Set();
+let browserTabGroupingEnabled = true;
+async function loadBrowserTabGroupingPreference() {
+ try {
+ const local = chrome.storage?.local;
+ if (!local) return;
+ const raw = await local.get(BROWSER_TAB_GROUPS_ENABLED_KEY);
+ browserTabGroupingEnabled = raw[BROWSER_TAB_GROUPS_ENABLED_KEY] !== false;
+ } catch {
+ browserTabGroupingEnabled = true;
+ }
+}
class CommandFailure extends Error {
constructor(code, message, hint) {
super(message);
@@ -1356,7 +1368,7 @@ async function createOwnedGroup(role, windowId, ids) {
return { id: group.id, windowId: group.windowId, title: group.title };
}
async function ensureOwnedContainerGroup(role, fallbackWindowId, tabIds) {
- if (role === "automation") return null;
+ if (role === "automation" || !browserTabGroupingEnabled) return null;
const ids = [...new Set(tabIds.filter((id) => id !== void 0))];
const container = ownedContainers[role];
const previousGroupPromise = container.groupPromise ?? Promise.resolve(null);
@@ -1367,6 +1379,26 @@ async function ensureOwnedContainerGroup(role, fallbackWindowId, tabIds) {
container.groupPromise = trackedGroupPromise;
return trackedGroupPromise;
}
+async function removeOwnedBrowserTabGroups() {
+ const container = ownedContainers.interactive;
+ const pendingGroup = container.groupPromise;
+ if (pendingGroup) await pendingGroup.catch(() => null);
+ const groups = await chrome.tabGroups.query({ title: CONTAINER_TAB_GROUP_TITLE.interactive }).catch(() => []);
+ for (const group of groups) {
+ const tabs = await chrome.tabs.query({ groupId: group.id }).catch(() => []);
+ const tabIds = tabs.map((tab) => tab.id).filter((id) => id !== void 0);
+ if (tabIds.length > 0) await chrome.tabs.ungroup(tabIds).catch(() => {
+ });
+ }
+ container.groupId = null;
+ interactiveGroupLedger.clear();
+ await persistRuntimeState();
+}
+async function setBrowserTabGroupingEnabled(enabled) {
+ await chrome.storage?.local?.set?.({ [BROWSER_TAB_GROUPS_ENABLED_KEY]: enabled });
+ browserTabGroupingEnabled = enabled;
+ if (!enabled) await withLeaseMutation(removeOwnedBrowserTabGroups);
+}
async function ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids) {
try {
const candidates = await collectOwnedGroupCandidates(role);
@@ -1610,7 +1642,9 @@ function initialize() {
workerRecovered = false;
workerReady = (async () => {
await getCurrentContextId();
+ await loadBrowserTabGroupingPreference();
await reconcileTargetLeaseRegistry();
+ if (!browserTabGroupingEnabled) await removeOwnedBrowserTabGroups();
})().catch((err) => {
console.warn(`[opencli] Startup recovery failed: ${err instanceof Error ? err.message : String(err)}`);
}).finally(() => {
@@ -1640,6 +1674,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === "getStatus") {
void (async () => {
+ await workerReady;
const contextId = await getCurrentContextId();
const connected = ws?.readyState === WebSocket.OPEN;
const extensionVersion = chrome.runtime.getManifest().version;
@@ -1649,11 +1684,28 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
reconnecting: reconnectTimer !== null,
contextId,
extensionVersion,
- daemonVersion
+ daemonVersion,
+ browserTabGroupingEnabled
});
})();
return true;
}
+ if (msg?.type === "setBrowserTabGrouping" && typeof msg.enabled === "boolean") {
+ void (async () => {
+ await workerReady;
+ try {
+ await setBrowserTabGroupingEnabled(msg.enabled);
+ sendResponse({ ok: true, browserTabGroupingEnabled });
+ } catch (err) {
+ sendResponse({
+ ok: false,
+ error: err instanceof Error ? err.message : String(err),
+ browserTabGroupingEnabled
+ });
+ }
+ })();
+ return true;
+ }
return false;
});
async function fetchDaemonVersion() {
diff --git a/extension/popup.html b/extension/popup.html
index 36527991b..9f7fa1fd4 100644
--- a/extension/popup.html
+++ b/extension/popup.html
@@ -64,6 +64,25 @@
border-top: 1px solid #ececec;
background: #fff;
}
+ .setting-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-top: 1px solid #ececec;
+ background: #fff;
+ }
+ .setting-label {
+ flex: 1;
+ font-size: 12px;
+ color: #1d1d1f;
+ }
+ .setting-toggle {
+ width: 16px;
+ height: 16px;
+ accent-color: #007aff;
+ cursor: pointer;
+ }
.profile-label {
font-size: 11px;
color: #86868b;
@@ -134,6 +153,10 @@
OpenCLI
+
The extension connects automatically when you run any opencli command.
diff --git a/extension/popup.js b/extension/popup.js
index 323e95cf7..8d42ccaca 100644
--- a/extension/popup.js
+++ b/extension/popup.js
@@ -9,6 +9,22 @@ chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => {
const copyBtn = document.getElementById('copyBtn');
const hint = document.getElementById('hint');
const extVersion = document.getElementById('extVersion');
+ const browserTabGrouping = document.getElementById('browserTabGrouping');
+
+ browserTabGrouping.checked = resp?.browserTabGroupingEnabled !== false;
+ browserTabGrouping.disabled = false;
+ browserTabGrouping.addEventListener('change', () => {
+ const enabled = browserTabGrouping.checked;
+ browserTabGrouping.disabled = true;
+ chrome.runtime.sendMessage({ type: 'setBrowserTabGrouping', enabled }, (result) => {
+ if (chrome.runtime.lastError || !result?.ok) {
+ browserTabGrouping.checked = !enabled;
+ } else {
+ browserTabGrouping.checked = result.browserTabGroupingEnabled;
+ }
+ browserTabGrouping.disabled = false;
+ });
+ });
if (resp && typeof resp.extensionVersion === 'string') {
extVersion.textContent = `v${resp.extensionVersion}`;
diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts
index e45614843..8c5d4867f 100644
--- a/extension/src/background.test.ts
+++ b/extension/src/background.test.ts
@@ -750,7 +750,10 @@ describe('background tab isolation', () => {
it('returns the persisted profile contextId from popup status', async () => {
const { chrome } = createChromeMock();
- await chrome.storage.local.set({ opencli_context_id_v1: 'abc123xy' });
+ await chrome.storage.local.set({
+ opencli_context_id_v1: 'abc123xy',
+ opencli_browser_tab_groups_enabled: false,
+ });
vi.stubGlobal('chrome', chrome);
await import('./background');
@@ -763,6 +766,7 @@ describe('background tab isolation', () => {
await vi.waitFor(() => {
expect(sendResponse).toHaveBeenCalledWith(expect.objectContaining({
contextId: 'abc123xy',
+ browserTabGroupingEnabled: false,
}));
});
});
@@ -1248,6 +1252,57 @@ describe('background tab isolation', () => {
expect(chrome.tabGroups.update).not.toHaveBeenCalled();
});
+ it('keeps browser tabs ungrouped when browser tab grouping is disabled', async () => {
+ const { chrome, tabs, groups } = createChromeMock();
+ await chrome.storage.local.set({ opencli_browser_tab_groups_enabled: false });
+ vi.stubGlobal('chrome', chrome);
+
+ const mod = await import('./background');
+ const tabId = await mod.__test__.resolveTabId(undefined, browserKey('default'));
+
+ expect(tabId).toBe(1);
+ expect(tabs.find((tab) => tab.id === tabId)?.groupId).toBe(-1);
+ expect(groups).toEqual([]);
+ expect(chrome.tabs.group).not.toHaveBeenCalled();
+ expect(chrome.tabGroups.update).not.toHaveBeenCalled();
+ });
+
+ it('ungroups live OpenCLI Browser tabs and prevents new groups when the setting is disabled', async () => {
+ const { chrome, tabs, groups } = createChromeMock();
+ vi.stubGlobal('chrome', chrome);
+
+ const mod = await import('./background');
+ const firstTabId = await mod.__test__.resolveTabId(undefined, browserKey('first'));
+ expect(tabs.find((tab) => tab.id === firstTabId)?.groupId).toBe(100);
+ expect(groups).toHaveLength(1);
+
+ const onMessageListener = chrome.runtime.onMessage.addListener.mock.calls[0][0];
+ const response = await new Promise((resolve) => {
+ const keepAlive = onMessageListener(
+ { type: 'setBrowserTabGrouping', enabled: false },
+ {},
+ resolve,
+ );
+ expect(keepAlive).toBe(true);
+ });
+
+ expect(response).toEqual({ ok: true, browserTabGroupingEnabled: false });
+ expect(tabs.find((tab) => tab.id === firstTabId)?.groupId).toBe(-1);
+ expect(groups).toEqual([]);
+ expect(mod.__test__.getInteractiveContainer()).toEqual(expect.objectContaining({
+ groupId: null,
+ groupIds: [],
+ }));
+
+ chrome.tabs.group.mockClear();
+ chrome.tabGroups.update.mockClear();
+ const secondTabId = await mod.__test__.resolveTabId(undefined, browserKey('second'));
+
+ expect(tabs.find((tab) => tab.id === secondTabId)?.groupId).toBe(-1);
+ expect(chrome.tabs.group).not.toHaveBeenCalled();
+ expect(chrome.tabGroups.update).not.toHaveBeenCalled();
+ });
+
it('keeps browser groups while adapter sessions stay ungrouped in separate owned windows', async () => {
const { chrome, tabs, groups } = createChromeMock();
let nextWindowId = 20;
diff --git a/extension/src/background.ts b/extension/src/background.ts
index 4918894c2..a7654d2be 100644
--- a/extension/src/background.ts
+++ b/extension/src/background.ts
@@ -299,6 +299,7 @@ const IDLE_TIMEOUT_DEFAULT = 30_000; // 30s — adapter-driven automation
const IDLE_TIMEOUT_INTERACTIVE = 600_000; // 10min — human-paced browser:* / operate:*
const IDLE_TIMEOUT_NONE = -1; // borrowed bound tabs stay bound until unbound/closed
const REGISTRY_KEY = 'opencli_target_lease_registry_v2';
+const BROWSER_TAB_GROUPS_ENABLED_KEY = 'opencli_browser_tab_groups_enabled';
const LEASE_IDLE_ALARM_PREFIX = 'opencli:lease-idle:';
const CONTAINER_TAB_GROUP_TITLE: Record = {
interactive: 'OpenCLI Browser',
@@ -326,6 +327,18 @@ const ownedContainers: Record();
+let browserTabGroupingEnabled = true;
+
+async function loadBrowserTabGroupingPreference(): Promise {
+ try {
+ const local = chrome.storage?.local;
+ if (!local) return;
+ const raw = await local.get(BROWSER_TAB_GROUPS_ENABLED_KEY) as Record;
+ browserTabGroupingEnabled = raw[BROWSER_TAB_GROUPS_ENABLED_KEY] !== false;
+ } catch {
+ browserTabGroupingEnabled = true;
+ }
+}
type StoredLease = Omit & {
idleDeadlineAt: number;
@@ -863,7 +876,7 @@ async function ensureOwnedContainerGroup(
// Adapter automation runs in an owned background window but no longer creates
// a visible "OpenCLI Adapter" tab group. Its ownership anchors are the
// persisted container windowId and per-lease preferredTabId.
- if (role === 'automation') return null;
+ if (role === 'automation' || !browserTabGroupingEnabled) return null;
const ids = [...new Set(tabIds.filter((id): id is number => id !== undefined))];
@@ -879,6 +892,29 @@ async function ensureOwnedContainerGroup(
return trackedGroupPromise;
}
+async function removeOwnedBrowserTabGroups(): Promise {
+ const container = ownedContainers.interactive;
+ const pendingGroup = container.groupPromise;
+ if (pendingGroup) await pendingGroup.catch(() => null);
+
+ const groups = await chrome.tabGroups.query({ title: CONTAINER_TAB_GROUP_TITLE.interactive }).catch(() => []);
+ for (const group of groups) {
+ const tabs = await chrome.tabs.query({ groupId: group.id }).catch(() => []);
+ const tabIds = tabs.map((tab) => tab.id).filter((id): id is number => id !== undefined);
+ if (tabIds.length > 0) await chrome.tabs.ungroup(tabIds).catch(() => {});
+ }
+
+ container.groupId = null;
+ interactiveGroupLedger.clear();
+ await persistRuntimeState();
+}
+
+async function setBrowserTabGroupingEnabled(enabled: boolean): Promise {
+ await chrome.storage?.local?.set?.({ [BROWSER_TAB_GROUPS_ENABLED_KEY]: enabled });
+ browserTabGroupingEnabled = enabled;
+ if (!enabled) await withLeaseMutation(removeOwnedBrowserTabGroups);
+}
+
async function ensureOwnedContainerGroupUnlocked(
role: OwnedWindowRole,
fallbackWindowId: number | null,
@@ -1212,7 +1248,9 @@ function initialize(): void {
workerRecovered = false;
workerReady = (async () => {
await getCurrentContextId();
+ await loadBrowserTabGroupingPreference();
await reconcileTargetLeaseRegistry();
+ if (!browserTabGroupingEnabled) await removeOwnedBrowserTabGroups();
})().catch((err) => {
// Never leave workerReady rejected/pending: a wedged gate would freeze
// every gated handler for the life of the worker.
@@ -1258,6 +1296,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === 'getStatus') {
void (async () => {
+ await workerReady;
const contextId = await getCurrentContextId();
const connected = ws?.readyState === WebSocket.OPEN;
const extensionVersion = chrome.runtime.getManifest().version;
@@ -1268,10 +1307,27 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
contextId,
extensionVersion,
daemonVersion,
+ browserTabGroupingEnabled,
});
})();
return true;
}
+ if (msg?.type === 'setBrowserTabGrouping' && typeof msg.enabled === 'boolean') {
+ void (async () => {
+ await workerReady;
+ try {
+ await setBrowserTabGroupingEnabled(msg.enabled);
+ sendResponse({ ok: true, browserTabGroupingEnabled });
+ } catch (err) {
+ sendResponse({
+ ok: false,
+ error: err instanceof Error ? err.message : String(err),
+ browserTabGroupingEnabled,
+ });
+ }
+ })();
+ return true;
+ }
return false;
});