diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index 729dae1..6cfc72b 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -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 = 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 { + 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, ) diff --git a/cli/src/native/cdp/types.rs b/cli/src/native/cdp/types.rs index 54eb184..1a2546a 100644 --- a/cli/src/native/cdp/types.rs +++ b/cli/src/native/cdp/types.rs @@ -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, } #[derive(Debug, Deserialize)] diff --git a/cli/src/native/daemon.rs b/cli/src/native/daemon.rs index 760f5f9..6c399f0 100644 --- a/cli/src/native/daemon.rs +++ b/cli/src/native/daemon.rs @@ -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); diff --git a/cli/src/native/state.rs b/cli/src/native/state.rs index 882764b..01e9483 100644 --- a/cli/src/native/state.rs +++ b/cli/src/native/state.rs @@ -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, ) diff --git a/extensions/ab-connect.crx b/extensions/ab-connect.crx index dddfddc..3561590 100644 Binary files a/extensions/ab-connect.crx and b/extensions/ab-connect.crx differ diff --git a/extensions/ab-connect.zip b/extensions/ab-connect.zip index 3d0618b..77ef0cd 100644 Binary files a/extensions/ab-connect.zip and b/extensions/ab-connect.zip differ diff --git a/extensions/ab-connect/background.js b/extensions/ab-connect/background.js index ed35ee0..d7b314c 100644 --- a/extensions/ab-connect/background.js +++ b/extensions/ab-connect/background.js @@ -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') { diff --git a/extensions/ab-connect/manifest.json b/extensions/ab-connect/manifest.json index 3ba7998..73d0954 100644 --- a/extensions/ab-connect/manifest.json +++ b/extensions/ab-connect/manifest.json @@ -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",