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
37 changes: 37 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,43 @@ 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}` になり、完全一致する Tab Group があれば再利用します。`groupName` が未指定または空白の場合は `default` になり、`AutoCLI-default` を使用します。このモードでは、新しい可視 Chrome ウィンドウを作らず、AutoCLI タブへ切り替えず、現在のアクティブタブを奪いません。Chrome、拡張機能、または既存の通常 Chrome ウィンドウが利用できない場合は、新規ウィンドウやアクティブ化へのフォールバックをせず、明示的に失敗します。`tabs select` はこのモードでは使用できません。

コマンド終了後は、連続コマンドのために同じグループを短時間保持します。アイドルタイムアウト後は、そのグループ内で 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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,43 @@ 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}`; an exact-title Tab Group is reused. If `groupName` is omitted or blank, it defaults to `default`, producing `AutoCLI-default`. 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.

After a command ends, the same group is kept briefly for consecutive commands. When it becomes idle, AutoCLI removes only tabs it created in that group. 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
37 changes: 37 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,43 @@ 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}`,同名 Tab Group 会被复用;未设置或留空 `groupName` 时默认为 `default`,即 `AutoCLI-default`。该模式严格不创建新的可见 Chrome 窗口、不切换到 AutoCLI 标签、不抢当前活动标签;如果 Chrome、扩展或现有普通 Chrome 窗口不可用,会直接报错,不会自动回退到新窗口或激活标签。`tabs select` 在该模式下不可用。

命令结束时会短暂保留同一组用于连续命令;空闲超时后只清理 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
98 changes: 92 additions & 6 deletions crates/autocli-browser/src/bridge.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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 crate::config::{load_browser_config_with_options, BrowserCarrier, BrowserConfig};
use crate::daemon_client::DaemonClient;
use crate::page::DaemonPage;

Expand All @@ -17,18 +18,44 @@ 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>,
}

impl BrowserBridge {
pub fn new(port: u16) -> Self {
Self { port }
Self {
port,
browser_config: None,
browser_options: None,
}
}

pub fn with_browser_config(port: u16, browser_config: BrowserConfig) -> Self {
Self {
port,
browser_config: Some(browser_config),
browser_options: None,
}
}

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)
)
}

/// 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 +64,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 +89,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,
"default",
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 +118,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,
"default",
browser_config.clone(),
)));
}

warn!("Chrome extension is not connected to the daemon");
Expand Down Expand Up @@ -165,6 +219,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 +309,24 @@ 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());
}
}
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