feat(connect): per-session Chrome tab groups on the shared real browser

Shared browser, separate tab groups: when an agent drives the user's real Chrome
via ab-connect, every tab it opens lands in a Chrome tab group named after its
--session (stable color per name). Each agent's tabs stay visually separated from
other agents' and from the user's own (ungrouped) tabs. Visibility is NOT
restricted — all agents still see all tabs (per design).

- CreateTargetParams gains an optional non-CDP `agentGroup` hint (skip-if-none),
  so a strict real-Chrome endpoint never receives it
- BrowserManager.agent_group(): Some(session) only when ws_url == the live
  ab-connect relay URL (never on launched/direct CDP); DAEMON_SESSION set at
  daemon start supplies the name; emitted at all createTarget sites (transient
  storage target stays None)
- ab-connect: +tabGroups permission; Target.createTarget reads agentGroup and
  groups the new tab (create/reuse by title, deterministic color), best-effort
- extension 0.3.0 -> 0.4.0; re-signed crx + zip (id unchanged)

Needs the v0.4.0 extension reloaded + a build with this change to take effect.
This commit is contained in:
leeguooooo
2026-06-09 21:17:53 +09:00
parent 85f4635358
commit d232763ff7
8 changed files with 93 additions and 1 deletions
+32
View File
@@ -12,6 +12,11 @@ use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, Lightpa
use super::cdp::types::*;
use super::element::{resolve_element_object_id, RefMap};
/// The daemon's session name, set once at daemon start. Names the Chrome tab
/// group that abs-created tabs land in when driving the user's real Chrome via
/// the `ab-connect` extension, so each agent/session gets its own group.
pub static DAEMON_SESSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
// ---------------------------------------------------------------------------
// Launch validation
// ---------------------------------------------------------------------------
@@ -568,12 +573,14 @@ impl BrowserManager {
if page_targets.is_empty() {
// Create a new tab
let agent_group = self.agent_group();
let result: CreateTargetResult = self
.client
.send_command_typed(
"Target.createTarget",
&CreateTargetParams {
url: "about:blank".to_string(),
agent_group,
},
None,
)
@@ -958,12 +965,14 @@ impl BrowserManager {
return Ok(());
}
let agent_group = self.agent_group();
let result: CreateTargetResult = self
.client
.send_command_typed(
"Target.createTarget",
&CreateTargetParams {
url: "about:blank".to_string(),
agent_group,
},
None,
)
@@ -1072,6 +1081,27 @@ impl BrowserManager {
self.pages.iter().any(|p| p.label.as_deref() == Some(label))
}
/// Chrome tab-group name for tabs this manager creates, or `None` when not
/// driving the user's real Chrome via the `ab-connect` extension relay.
///
/// Grouping only makes sense on the shared real browser (one Chrome, many
/// agents): each session's tabs go into its own group. On a launched / direct
/// CDP browser the endpoint is strict, so we must NOT send the custom param —
/// hence `None` there. We detect the relay by matching our `ws_url` against
/// the live relay URL the native-messaging host published.
fn agent_group(&self) -> Option<String> {
let via_relay = crate::connect::relay_url().as_deref() == Some(self.ws_url.as_str());
if !via_relay {
return None;
}
let name = DAEMON_SESSION.get().map(String::as_str).unwrap_or("default");
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
pub async fn tab_new(
&mut self,
url: Option<&str>,
@@ -1096,12 +1126,14 @@ impl BrowserManager {
let target_url = url.unwrap_or("about:blank");
let agent_group = self.agent_group();
let result: CreateTargetResult = self
.client
.send_command_typed(
"Target.createTarget",
&CreateTargetParams {
url: target_url.to_string(),
agent_group,
},
None,
)
+6
View File
@@ -141,6 +141,12 @@ pub struct SetDiscoverTargetsParams {
#[serde(rename_all = "camelCase")]
pub struct CreateTargetParams {
pub url: String,
/// Non-CDP hint consumed only by the `ab-connect` extension: the Chrome
/// tab-group name to drop the new tab into (per-session grouping on the
/// shared real Chrome). `None` on the normal CDP path so a strict real-Chrome
/// endpoint never receives an unknown parameter.
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_group: Option<String>,
}
#[derive(Debug, Deserialize)]
+4
View File
@@ -17,6 +17,10 @@ use super::state;
use super::stream::StreamServer;
pub async fn run_daemon(session: &str) {
// Record this daemon's session so tabs it opens on the shared real Chrome
// (via the ab-connect extension) land in a per-session Chrome tab group.
let _ = super::browser::DAEMON_SESSION.set(session.to_string());
let socket_dir = get_daemon_socket_dir();
if !socket_dir.exists() {
let _ = fs::create_dir_all(&socket_dir);
+2
View File
@@ -119,6 +119,8 @@ async fn collect_storage_via_temp_target(
"Target.createTarget",
&CreateTargetParams {
url: "about:blank".to_string(),
// Transient internal target (storage collection) — never grouped.
agent_group: None,
},
None,
)
Binary file not shown.
Binary file not shown.
+47
View File
@@ -28,6 +28,46 @@ const tabs = new Map()
const sessionToTab = new Map()
/** child (OOPIF/worker) sessionId -> tabId */
const childSessionToTab = new Map()
/** tab-group name -> chrome tabGroups id (best-effort cache) */
const groupIdByName = new Map()
// Deterministic color per group name so a given session keeps the same color.
const GROUP_COLORS = ['blue', 'cyan', 'green', 'yellow', 'orange', 'red', 'pink', 'purple', 'grey']
function colorForName(name) {
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return GROUP_COLORS[h % GROUP_COLORS.length]
}
// Put a freshly-created tab into the agent/session's own Chrome tab group, so
// each agent's tabs are visually separated (from each other and from the user's
// own tabs) on the shared real browser. Best-effort: grouping failures never
// break tab creation.
async function groupTabInto(tabId, name) {
if (!name || !chrome.tabGroups || !chrome.tabs.group) return
const tab = await chrome.tabs.get(tabId).catch(() => null)
if (!tab) return
let gid = groupIdByName.get(name)
if (gid != null) {
const ok = await chrome.tabGroups.get(gid).then(() => true).catch(() => false)
if (!ok) {
gid = null
groupIdByName.delete(name)
}
}
if (gid == null) {
// Reuse a same-titled group already in this window (survives SW restarts).
const found = await chrome.tabGroups.query({ windowId: tab.windowId, title: name }).catch(() => [])
if (found && found[0]) gid = found[0].id
}
if (gid == null) {
gid = await chrome.tabs.group({ tabIds: tabId })
await chrome.tabGroups.update(gid, { title: name, color: colorForName(name) }).catch(() => {})
} else {
await chrome.tabs.group({ groupId: gid, tabIds: tabId }).catch(() => {})
}
groupIdByName.set(name, gid)
}
function postToHost(msg) {
try {
@@ -121,6 +161,13 @@ async function handleForwardCdpCommand(msg) {
if (!tab.id) throw new Error('createTarget: no tab id')
await new Promise((r) => setTimeout(r, 100))
const t = await attachTab(tab.id)
// Per-session tab grouping (non-CDP hint from the daemon). Best-effort.
const group = typeof params?.agentGroup === 'string' ? params.agentGroup.trim() : ''
if (group) {
try {
await groupTabInto(tab.id, group)
} catch {}
}
return { targetId: t.targetId }
}
if (method === 'Target.closeTarget') {
+2 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "agent-browser connect",
"version": "0.3.0",
"version": "0.4.0",
"description": "Let agent-browser drive your logged-in Chrome — install once, no token, no per-use confirmation.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
"icons": {
@@ -13,6 +13,7 @@
"permissions": [
"debugger",
"tabs",
"tabGroups",
"nativeMessaging",
"storage",
"alarms",