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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
@@ -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') {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user