Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,45 @@ cp target/release/autocli /usr/local/bin/ # macOS / Linux

> Public モードのコマンド(hackernews、devto、lobsters など)は拡張機能なしで使用できます。

### 任意: Chrome Tab Group 厳格バックグラウンドモード

ブラウザコマンドは、デフォルトでは従来どおり automation window を使用します。新しい可視 Chrome ウィンドウを作りたくない場合は、Tab Group 厳格バックグラウンドモードを明示的に有効化できます。

```json
{
"browser": {
"carrier": "tab-group-background",
"groupName": "work",
"groupIdleTimeoutSeconds": 30
}
}
```

環境変数でも上書きできます。

```bash
AUTOCLI_BROWSER_CARRIER=tab-group-background
AUTOCLI_BROWSER_GROUP_NAME=work
AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT=30
```

実際のグループ名は `AutoCLI-${groupName}` になり、Chrome プロファイル内ではタイトルが完全一致する Tab Group をグローバルに一つだけ再利用します。`groupName` が未指定または空白の場合は `default` になり、`AutoCLI-default` を使用します。同じタイトルの開いているグループが複数ある場合、どのグループまたはタブを管理するか推測せず、コマンドは失敗します。このモードでは、新しい可視 Chrome ウィンドウを作らず、AutoCLI タブへ切り替えず、現在のアクティブタブを奪いません。Chrome、拡張機能、または既存の通常 Chrome ウィンドウが利用できない場合は、新規ウィンドウやアクティブ化へのフォールバックをせず、明示的に失敗します。`tabs select` はこのモードでは使用できません。

`workspace` は Web サイトではなく、グループ名にも参加しません。共有グループ内の論理的な呼び出し元と、その `tabId` の所有権を識別するためだけに使われます。同じ `groupName` の呼び出しは、異なるサイトにアクセスする場合でも一つの物理グループを共有し、自分で作成したタブだけを操作またはクリーンアップします。他の呼び出し元やユーザー既存のタブを選択・削除することはありません。

コマンド終了後は、連続コマンドのために同じグループを短時間保持します。strict-background モードでは、`groupIdleTimeoutSeconds` は AutoCLI が作成した各タブのアイドル TTL です。拡張機能は各 `tabId` の最後に成功した AutoCLI 操作時刻を記録し、そのタブ自身がタイムアウトした場合だけ閉じます。別のタブの操作や `tabs list` では更新されません。グループごとに最も早く期限切れになる AutoCLI タブを処理するスケジューラを 1 つだけ持ち、AutoCLI 所有のタブがなくなったときだけセッション記録を解放します。所有権と操作時刻は `chrome.storage.session` に保存されるため、MV3 service worker の停止をまたいで保持されます。ただし、拡張機能の再読み込み・更新、またはブラウザ再起動後には意図的に破棄されます。Chrome の ID は現在のブラウザセッション内でのみ信頼できるため、その境界を越えた後に AutoCLI が所有権を推測したり、既存のタブを削除したりすることはありません。元に戻すには、これらの環境変数を削除するか、`carrier` を `automation-window` に戻してください。

カスタムローカル adapter でも、コマンドごとにブラウザ carrier を指定できます。この設定はグローバル設定と環境変数を上書きします。

```yaml
browser:
carrier: tab-group-background
groupName: work
groupIdleTimeoutSeconds: 30
```

既存の `browser: true` / `browser: false` 構文は引き続き互換です。adapter またはグローバル設定で `tab-group-background` を明示的に選び、有効な `groupName` がない場合は `default` を使用します。

## Skill インストール

ワンクリックで AI Agent に autocli skill をインストール:
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,45 @@ Simply re-run the install command or download the latest release to overwrite th

> Public mode commands (hackernews, devto, lobsters, etc.) work without the extension.

### Optional: Chrome Tab Group Strict Background Mode

Browser commands still use the existing automation window by default. To avoid creating a new visible Chrome window, explicitly enable Tab Group strict background mode:

```json
{
"browser": {
"carrier": "tab-group-background",
"groupName": "work",
"groupIdleTimeoutSeconds": 30
}
}
```

Environment variables can override the file config:

```bash
AUTOCLI_BROWSER_CARRIER=tab-group-background
AUTOCLI_BROWSER_GROUP_NAME=work
AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT=30
```

The actual group title is `AutoCLI-${groupName}`; one exact-title Tab Group is reused globally within the Chrome profile. If `groupName` is omitted or blank, it defaults to `default`, producing `AutoCLI-default`. If several open groups have the same title, the command fails rather than guessing which group or tabs to manage. This mode strictly avoids new visible Chrome windows, avoids switching to AutoCLI tabs, and avoids stealing the current active tab. If Chrome, the extension, or an existing normal Chrome window is unavailable, the command fails instead of falling back to a new window or an activated tab. `tabs select` is unsupported in this mode.

`workspace` is not a website and does not participate in group naming. It identifies a logical caller and its `tabId` ownership inside the shared group. Calls with the same `groupName`, even when they visit different sites, use that one physical group and can operate on or clean up only the tabs they created; they never select or remove another caller's or the user's existing tabs.

After a command ends, the same group is kept briefly for consecutive commands. In strict-background mode, `groupIdleTimeoutSeconds` is the idle TTL for each AutoCLI-created tab. The extension records the last successful AutoCLI operation for every `tabId` and closes that tab only after its own timeout; activity on another tab or `tabs list` does not renew it. One group-level scheduler always handles the earliest-expiring AutoCLI tab, and the session record is released only when no AutoCLI-owned tabs remain. Ownership and activity are kept in `chrome.storage.session`, so they survive MV3 service-worker suspension but are intentionally discarded after an extension reload, update, or browser restart: Chrome IDs are only safe within that browser session, and AutoCLI will not guess ownership or remove existing tabs after that boundary. To roll back, unset these environment variables or set `carrier` back to `automation-window`.

Custom local adapters can also choose the browser carrier per command, overriding global config and environment variables:

```yaml
browser:
carrier: tab-group-background
groupName: work
groupIdleTimeoutSeconds: 30
```

The existing `browser: true` / `browser: false` syntax remains compatible. If an adapter or global config explicitly selects `tab-group-background` without a valid `groupName`, AutoCLI uses `default`.

## Skill Install

One-click install autocli skill for your AI Agent:
Expand Down
39 changes: 39 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,45 @@ cp target/release/autocli /usr/local/bin/ # macOS / Linux

> Public 模式命令(hackernews、devto、lobsters 等)无需安装扩展即可使用。

### 可选:Chrome Tab Group 严格后台模式

浏览器命令默认仍使用原有的 automation window。需要避免新建可见 Chrome 窗口时,可以显式启用 Tab Group 严格后台模式:

```json
{
"browser": {
"carrier": "tab-group-background",
"groupName": "work",
"groupIdleTimeoutSeconds": 30
}
}
```

也可以用环境变量覆盖:

```bash
AUTOCLI_BROWSER_CARRIER=tab-group-background
AUTOCLI_BROWSER_GROUP_NAME=work
AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT=30
```

启用后实际组名为 `AutoCLI-${groupName}`;在同一 Chrome 配置文件中,全局复用唯一一个完全同名的 Tab Group。未设置或留空 `groupName` 时默认为 `default`,即 `AutoCLI-default`。如果同时存在多个同名的开放分组,命令会直接报错,而不会猜测应管理哪个分组或哪些标签。该模式严格不创建新的可见 Chrome 窗口、不切换到 AutoCLI 标签、不抢当前活动标签;如果 Chrome、扩展或现有普通 Chrome 窗口不可用,会直接报错,不会自动回退到新窗口或激活标签。`tabs select` 在该模式下不可用。

`workspace` 不是网站,也不参与分组命名;它只用于共享分组内的逻辑调用者和 `tabId` 所有权追踪。相同 `groupName` 的不同调用即使访问不同网站,也只使用这一个物理分组,并且只能操作或清理各自创建的标签,绝不会任选或删除其他调用及用户原有的标签。

命令结束时会短暂保留同一组用于连续命令;在严格后台模式下,`groupIdleTimeoutSeconds` 是每个 AutoCLI 创建标签的空闲 TTL。扩展会为每个 `tabId` 记录最后一次成功的 AutoCLI 操作时间,并在该标签自身超时后关闭它;对另一个标签的操作或 `tabs list` 不会续期它。一个组只保留一个调度器,始终处理最早到期的 AutoCLI 标签;只有没有剩余 AutoCLI 标签时才释放该组的会话记录。所有权和活动记录保存在 `chrome.storage.session` 中,因此可跨 MV3 service worker 挂起保留;但扩展重载、更新或浏览器重启后会有意丢弃这些记录:Chrome ID 仅在当前浏览器会话中可靠,跨越该边界后 AutoCLI 不会猜测所有权或删除已有标签。回滚到旧行为时,删除这些环境变量或将 `carrier` 改回 `automation-window`。

自定义本地 adapter 也可以单独指定浏览器承载方式,且会覆盖全局配置和环境变量:

```yaml
browser:
carrier: tab-group-background
groupName: work
groupIdleTimeoutSeconds: 30
```

现有写法 `browser: true` / `browser: false` 仍然兼容。若 adapter 或全局配置显式选择 `tab-group-background` 但没有提供有效 `groupName`,默认使用 `default`。

## Skill 安装

一键为你的 AI Agent 安装 autocli skill:
Expand Down
129 changes: 123 additions & 6 deletions crates/autocli-browser/src/bridge.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use autocli_core::{CliError, IPage};
use autocli_core::{BrowserOptions, CliError, IPage};
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, info, warn};
use uuid::Uuid;

use crate::config::{load_browser_config_with_options, BrowserCarrier, BrowserConfig};
use crate::daemon_client::DaemonClient;
use crate::page::DaemonPage;

Expand All @@ -17,18 +19,54 @@ const EXTENSION_POLL_INTERVAL: Duration = Duration::from_millis(500);
/// The daemon runs as a detached background process with its own idle-shutdown lifecycle.
pub struct BrowserBridge {
port: u16,
browser_config: Option<BrowserConfig>,
browser_options: Option<BrowserOptions>,
strict_background_workspace: String,
}

impl BrowserBridge {
pub fn new(port: u16) -> Self {
Self { port }
Self {
port,
browser_config: None,
browser_options: None,
strict_background_workspace: Uuid::new_v4().to_string(),
}
}

pub fn with_browser_config(port: u16, browser_config: BrowserConfig) -> Self {
Self {
port,
browser_config: Some(browser_config),
browser_options: None,
strict_background_workspace: Uuid::new_v4().to_string(),
}
}

pub fn with_browser_options(mut self, browser_options: Option<BrowserOptions>) -> Self {
self.browser_options = browser_options;
self
}

/// Create a bridge using the default port.
pub fn default_port() -> Self {
Self::new(DEFAULT_PORT)
}

fn should_wake_chrome_for_extension_recovery(&self) -> bool {
!matches!(
self.browser_config.as_ref().map(|cfg| &cfg.carrier),
Some(BrowserCarrier::TabGroupBackground)
)
}

fn workspace_for(&self, browser_config: &BrowserConfig) -> &str {
match browser_config.carrier {
BrowserCarrier::TabGroupBackground => &self.strict_background_workspace,
BrowserCarrier::AutomationWindow => "default",
}
}

/// Connect to the daemon, starting it if necessary, and return a trait-object page.
pub async fn connect(&mut self) -> Result<Arc<dyn IPage>, CliError> {
Ok(self.connect_daemon_page().await?)
Expand All @@ -37,6 +75,7 @@ impl BrowserBridge {
/// Connect and return the concrete `DaemonPage` so callers can use
/// daemon-specific methods (e.g. `read_article`) not on the `IPage` trait.
pub async fn connect_daemon_page(&mut self) -> Result<Arc<DaemonPage>, CliError> {
let browser_config = self.browser_config()?;
let client = Arc::new(DaemonClient::new(self.port));

// Step 1: Check Chrome is running
Expand All @@ -61,8 +100,27 @@ impl BrowserBridge {
}

// Step 3: Wait up to 5s for extension to connect
if self.poll_extension(&client, EXTENSION_INITIAL_WAIT, false).await {
return Ok(Arc::new(DaemonPage::new(client, "default")));
if self
.poll_extension(&client, EXTENSION_INITIAL_WAIT, false)
.await
{
return Ok(Arc::new(DaemonPage::new_with_browser_config(
client,
self.workspace_for(&browser_config),
browser_config.clone(),
)));
}

if !self.should_wake_chrome_for_extension_recovery() {
return Err(CliError::BrowserConnect {
message: "Chrome extension not connected in strict-background mode".into(),
suggestions: vec![
"Open an existing normal Chrome window with the AutoCLI extension already enabled".into(),
"Strict background mode will not open Chrome, create a new window, or wake the extension with about:blank".into(),
format!("The daemon is listening on port {}", self.port),
],
source: None,
});
}

// Step 4: Extension not connected — try to wake up Chrome
Expand All @@ -71,8 +129,15 @@ impl BrowserBridge {
wake_chrome();

// Step 5: Wait remaining 25s with progress
if self.poll_extension(&client, EXTENSION_REMAINING_WAIT, true).await {
return Ok(Arc::new(DaemonPage::new(client, "default")));
if self
.poll_extension(&client, EXTENSION_REMAINING_WAIT, true)
.await
{
return Ok(Arc::new(DaemonPage::new_with_browser_config(
client,
self.workspace_for(&browser_config),
browser_config.clone(),
)));
}

warn!("Chrome extension is not connected to the daemon");
Expand Down Expand Up @@ -165,6 +230,18 @@ impl BrowserBridge {
READY_TIMEOUT.as_secs()
)))
}

fn browser_config(&mut self) -> Result<BrowserConfig, CliError> {
if self.browser_config.is_none() {
self.browser_config = Some(load_browser_config_with_options(
self.browser_options.as_ref(),
)?);
}
Ok(self
.browser_config
.clone()
.unwrap_or_else(BrowserConfig::default))
}
}

/// Check if Chrome/Chromium is running as a process.
Expand Down Expand Up @@ -243,4 +320,44 @@ mod tests {
let bridge = BrowserBridge::default_port();
assert_eq!(bridge.port, DEFAULT_PORT);
}

#[test]
fn tab_group_background_does_not_use_chrome_wake_path() {
let bridge = BrowserBridge::with_browser_config(
19925,
BrowserConfig {
carrier: BrowserCarrier::TabGroupBackground,
group_name: Some("work".to_string()),
group_idle_timeout_seconds: Some(30),
},
);

assert!(!bridge.should_wake_chrome_for_extension_recovery());
}

#[test]
fn automation_window_keeps_chrome_wake_path() {
let bridge = BrowserBridge::new(19925);
assert!(bridge.should_wake_chrome_for_extension_recovery());
}

#[test]
fn tab_group_background_uses_a_unique_stable_workspace() {
let config = BrowserConfig {
carrier: BrowserCarrier::TabGroupBackground,
group_name: Some("work".to_string()),
group_idle_timeout_seconds: Some(30),
};
let first = BrowserBridge::with_browser_config(19925, config.clone());
let second = BrowserBridge::with_browser_config(19925, config.clone());

assert_eq!(first.workspace_for(&config), first.workspace_for(&config));
assert_ne!(first.workspace_for(&config), second.workspace_for(&config));
}

#[test]
fn automation_window_uses_default_workspace() {
let bridge = BrowserBridge::new(19925);
assert_eq!(bridge.workspace_for(&BrowserConfig::default()), "default");
}
}
21 changes: 8 additions & 13 deletions crates/autocli-browser/src/cdp.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use async_trait::async_trait;
use futures::{SinkExt, StreamExt};
use autocli_core::{
AutoScrollOptions, CliError, Cookie, CookieOptions, GotoOptions, IPage, InterceptedRequest,
NetworkRequest, ScreenshotOptions, SnapshotOptions, TabInfo, WaitOptions,
};
use futures::{SinkExt, StreamExt};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
Expand All @@ -15,8 +15,10 @@ use tracing::{debug, error};

use crate::dom_helpers;

type WsSink =
futures::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, Message>;
type WsSink = futures::stream::SplitSink<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
Message,
>;

/// Direct Chrome DevTools Protocol page client via WebSocket.
///
Expand Down Expand Up @@ -46,9 +48,7 @@ impl CdpPage {
Ok(Message::Text(text)) => {
if let Ok(json) = serde_json::from_str::<Value>(&text) {
if let Some(id) = json.get("id").and_then(|v| v.as_u64()) {
if let Some(tx) =
reader_pending.write().await.remove(&id)
{
if let Some(tx) = reader_pending.write().await.remove(&id) {
let _ = tx.send(json);
}
} else {
Expand Down Expand Up @@ -215,9 +215,7 @@ impl IPage for CdpPage {
}

async fn cookies(&self, _options: Option<CookieOptions>) -> Result<Vec<Cookie>, CliError> {
let result = self
.send_cdp("Network.getCookies", json!({}))
.await?;
let result = self.send_cdp("Network.getCookies", json!({})).await?;
let cookies_val = result.get("cookies").cloned().unwrap_or(json!([]));
let cookies: Vec<Cookie> = serde_json::from_value(cookies_val).unwrap_or_default();
Ok(cookies)
Expand Down Expand Up @@ -267,10 +265,7 @@ impl IPage for CdpPage {

async fn tabs(&self) -> Result<Vec<TabInfo>, CliError> {
let result = self.send_cdp("Target.getTargets", json!({})).await?;
let targets = result
.get("targetInfos")
.cloned()
.unwrap_or(json!([]));
let targets = result.get("targetInfos").cloned().unwrap_or(json!([]));
let mut tabs = Vec::new();
if let Some(arr) = targets.as_array() {
for t in arr {
Expand Down
Loading