diff --git a/README.md b/README.md
index f6a9907..7206739 100644
--- a/README.md
+++ b/README.md
@@ -22,39 +22,10 @@ The browser can run locally, on a remote server, or as an existing browser with
## Embedded experience
-
-
- ◉ BrowserKit Agent Workspace
- ● LIVE
-
-
- ← ↻
- https://example.com/workspace
- Take control
-
-
-
- Live browser surface
- Rendered by Chrome / Chromium and streamed over WebSocket.
- The agent is inspecting the page and interacting with a target…
- Mouse Touch Keyboard IME
-
-
- Diagnostics
- Console Network
- GET /api/items 200
- DOMContentLoaded
- 0 errors
-
-
-
- Tab: Workspace
- 1920 × 1080 Controller connected
-
-
+
-This repository-native HTML mockup represents the embedded layout. The actual `BrowserView` renders the live browser stream, tabs, address bar, input controls, and diagnostics directly where it is mounted.
+`BrowserView` renders the live browser stream, tabs, address bar, input controls, and diagnostics directly inside the host application's container.
@@ -119,7 +90,7 @@ Run build commands from the repository root and dependency installation commands
## 1. CLI and Skill
-The installer installs both the client and the Agent Skill:
+The installer builds the client and configures its Agent Skill. Codex is the default target:
```sh
./install.sh
@@ -128,8 +99,16 @@ browserkit --version
```
The default client directory is `~/.local/bin`.
-The default Skill directory is `${CODEX_HOME:-~/.codex}/skills/browserkit-cli`.
-Override them with `BROWSERKIT_BIN_DIR` and `BROWSERKIT_SKILL_DIR`.
+The default Codex Skill directory is `${CODEX_HOME:-~/.codex}/skills/browserkit-cli`.
+
+Configure the same Skill for Pi Agent, or for both agents:
+
+```sh
+./install.sh --agent pi
+./install.sh --agent all
+```
+
+Pi installs the Skill under `${PI_CODING_AGENT_DIR:-~/.pi/agent}/skills/browserkit-cli`; start a new Pi session and use `/skill:browserkit-cli` to invoke it explicitly. Override destinations with `BROWSERKIT_BIN_DIR`, `BROWSERKIT_CODEX_SKILL_DIR`, or `BROWSERKIT_PI_SKILL_DIR`. The legacy `BROWSERKIT_SKILL_DIR` override remains supported for a selected target.
Agent usage instructions are in the [Skill](skills/browserkit-cli/SKILL.md).
### Attach to an existing browser
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 363cc39..524dbca 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -21,39 +21,10 @@ Agent 可通过 CLI 或 Go 函数读取页面、操作元素和管理 Tab;用
## 嵌入效果
-
-
- ◉ BrowserKit Agent Workspace
- ● LIVE
-
-
- ← ↻
- https://example.com/workspace
- 接管操作
-
-
-
- 浏览器实时画面
- 由 Chrome / Chromium 渲染,通过 WebSocket 实时传输。
- Agent 正在检查页面内容并操作目标元素……
- 鼠标 触摸 键盘 中文输入法
-
-
- 开发诊断
- Console Network
- GET /api/items 200
- DOMContentLoaded
- 0 errors
-
-
-
- Tab:Workspace
- 1920 × 1080 控制方已连接
-
-
+
-这是基于 README 原生 HTML 的界面示意;实际 `BrowserView` 会在调用位置嵌入浏览器图流、Tab、地址栏、输入控制与诊断面板。
+`BrowserView` 会直接在宿主应用的容器内渲染浏览器图流、Tab、地址栏、输入控制与诊断面板。
@@ -118,7 +89,7 @@ go mod download
## 1. CLI 与 Skill
-安装脚本同时安装 client 和 Agent Skill:
+安装脚本会构建 client 并配置 Agent Skill,默认目标为 Codex:
```sh
./install.sh
@@ -127,7 +98,15 @@ browserkit --version
```
默认 client 目录为 `~/.local/bin`,Skill 目录为 `${CODEX_HOME:-~/.codex}/skills/browserkit-cli`。
-用 `BROWSERKIT_BIN_DIR` 和 `BROWSERKIT_SKILL_DIR` 修改安装位置。
+
+配置给 Pi Agent,或同时配置给两个 Agent:
+
+```sh
+./install.sh --agent pi
+./install.sh --agent all
+```
+
+Pi Skill 默认安装到 `${PI_CODING_AGENT_DIR:-~/.pi/agent}/skills/browserkit-cli`;新建 Pi 会话后可用 `/skill:browserkit-cli` 显式调用。使用 `BROWSERKIT_BIN_DIR`、`BROWSERKIT_CODEX_SKILL_DIR` 和 `BROWSERKIT_PI_SKILL_DIR` 修改对应位置;旧的 `BROWSERKIT_SKILL_DIR` 仍可覆盖所选目标的 Skill 目录。
Agent 操作约定见 [Skill](skills/browserkit-cli/SKILL.md)。
### 接管已有浏览器
diff --git a/attach.go b/attach.go
index 5182caf..9e4d3b4 100644
--- a/attach.go
+++ b/attach.go
@@ -68,6 +68,17 @@ func attachPage(ctx context.Context, browser Session) (Page, error) {
if err != nil {
return nil, err
}
+ if detector, ok := browser.(ActiveTabSession); ok {
+ targetID, err := detector.ActiveTab(ctx, listed)
+ if err != nil {
+ return nil, err
+ }
+ page, err := tabs.PageForTab(ctx, targetID)
+ if err != nil {
+ return nil, fmt.Errorf("绑定激活 Tab: %w", err)
+ }
+ return page, nil
+ }
return activePage(ctx, tabs, listed)
}
diff --git a/attach_test.go b/attach_test.go
index 01a733a..ffc6642 100644
--- a/attach_test.go
+++ b/attach_test.go
@@ -3,6 +3,7 @@ package browserkit
import (
"context"
"encoding/json"
+ "errors"
"testing"
)
@@ -103,12 +104,46 @@ func (p *focusPage) Evaluate(context.Context, string, EvaluateOptions) (Evaluate
type focusSession struct {
*fakeViewportSession
- pages map[string]*focusPage
+ pages map[string]*focusPage
+ pageCalls []string
}
func (s *focusSession) PageForTab(_ context.Context, id string) (Page, error) {
+ s.pageCalls = append(s.pageCalls, id)
return s.pages[id], nil
}
+
+type activeTargetSession struct {
+ *focusSession
+ activeTarget string
+ probeCalls int
+}
+
+func (s *activeTargetSession) ActiveTab(_ context.Context, listed []TabInfo) (string, error) {
+ s.probeCalls++
+ if len(listed) == 0 {
+ return "", errors.New("missing tabs")
+ }
+ return s.activeTarget, nil
+}
+
+func TestAttachBindsOnlyProbedActiveTab(t *testing.T) {
+ a := &focusPage{fakePage: &fakePage{info: PageInfo{URL: "https://example.com/a"}}}
+ b := &focusPage{fakePage: &fakePage{info: PageInfo{URL: "https://example.com/b"}}}
+ legacy := &focusSession{
+ fakeViewportSession: &fakeViewportSession{fakeSession: &fakeSession{}, tabs: []TabInfo{{TargetID: "a", Type: "page"}, {TargetID: "b", Type: "page"}}},
+ pages: map[string]*focusPage{"a": a, "b": b},
+ }
+ session := &activeTargetSession{focusSession: legacy, activeTarget: "b"}
+ page, err := attachPage(context.Background(), session)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if page != b || session.probeCalls != 1 || len(session.pageCalls) != 1 || session.pageCalls[0] != "b" {
+ t.Fatalf("page=%p probes=%d bindings=%v", page, session.probeCalls, session.pageCalls)
+ }
+}
+
func TestAttachActiveTab(t *testing.T) {
for _, tc := range []struct {
name string
diff --git a/docs/images/browser-embedded.svg b/docs/images/browser-embedded.svg
new file mode 100644
index 0000000..364fc06
--- /dev/null
+++ b/docs/images/browser-embedded.svg
@@ -0,0 +1,166 @@
+
+ BrowserKit embedded browser interface
+ A BrowserView styled like the real component, with tabs, an address bar, a live page, and the developer tools panel below it.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fieldnotes · Workspace
+
+
+
+ Project details
+
+
+
+
+
+
+
+
+
+ http://127.0.0.1:8877/site/
+
+
+
+
+ F12
+
+
+
+
+
+
+
+ ◈ Fieldnotes
+ Workspace
+ Projects / 2026
+
+ INPUT AND NAVIGATION
+ Work notes
+ Add a note, send a request, or open project details.
+
+
+ New note
+ Note
+
+ Enter a note
+
+ Save note
+
+ Test request
+ Request succeeded
+
+
+ Projects
+
+ Website redesign
+ Done
+
+
+ Mobile layout
+ Done
+
+
+ API testing
+ In progress
+
+ Open project details ↗
+
+
+
+
+
+
+
+
+
+
+ Developer tools
+ Console
+ Network
+
+ Resources
+ Collecting
+
+ Stop
+
+
+
+
+
+ All
+
+ Fetch/XHR
+
+ Document
+
+ Script
+
+ Filter requests
+
+
+
+ Name
+ Status
+ Method
+ Type
+ Time
+
+
+
+ status
+ 200
+ GET
+ fetch
+ 18 ms
+
+
+
+ index.html
+ 200
+ GET
+ document
+ 11 ms
+
+
+
+ favicon.ico
+ 200
+ GET
+ image
+ 3 ms
+
+
+
+ 3 requests
+ 0 errors
+
+
+
+
diff --git a/install.sh b/install.sh
index 1563264..c972acd 100755
--- a/install.sh
+++ b/install.sh
@@ -1,16 +1,77 @@
#!/bin/sh
set -eu
+usage() {
+ cat <<'EOF'
+Usage: ./install.sh [--agent codex|pi|all]
+
+Build browserkit and install its Agent Skill. The default agent is codex.
+
+Environment:
+ BROWSERKIT_AGENT Default agent when --agent is omitted.
+ BROWSERKIT_BIN_DIR Binary destination (default: ~/.local/bin).
+ BROWSERKIT_SKILL_DIR Legacy override for one selected agent.
+ BROWSERKIT_CODEX_SKILL_DIR Codex Skill destination.
+ BROWSERKIT_PI_SKILL_DIR Pi Agent Skill destination.
+ CODEX_HOME Codex data directory (default: ~/.codex).
+ PI_CODING_AGENT_DIR Pi Agent data directory (default: ~/.pi/agent).
+EOF
+}
+
+agent=${BROWSERKIT_AGENT:-codex}
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --agent)
+ [ "$#" -ge 2 ] || { printf '%s\n' '缺少 --agent 参数。' >&2; usage >&2; exit 2; }
+ agent=$2
+ shift 2
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ *)
+ printf '未知参数: %s\n' "$1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+case "$agent" in
+ codex|pi|all) ;;
+ *)
+ printf '不支持的 Agent: %s(可选 codex、pi、all)\n' "$agent" >&2
+ exit 2
+ ;;
+esac
+
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
bin_dir=${BROWSERKIT_BIN_DIR:-"${HOME}/.local/bin"}
-skill_root=${BROWSERKIT_SKILL_DIR:-"${CODEX_HOME:-${HOME}/.codex}/skills/browserkit-cli"}
-mkdir -p "$bin_dir" "$skill_root"
+mkdir -p "$bin_dir"
bin_dir=$(CDPATH= cd -- "$bin_dir" && pwd)
(cd "$repo_root" && go build -o "$bin_dir/browserkit" ./cmd/browserkit)
-cp "$repo_root/skills/browserkit-cli/SKILL.md" "$skill_root/SKILL.md"
+
+install_skill() {
+ skill_root=$1
+ mkdir -p "$skill_root"
+ skill_root=$(CDPATH= cd -- "$skill_root" && pwd)
+ cp "$repo_root/skills/browserkit-cli/SKILL.md" "$skill_root/SKILL.md"
+ printf '已为 %s 安装 Skill: %s\n' "$2" "$skill_root/SKILL.md"
+}
+
+legacy_skill_root=${BROWSERKIT_SKILL_DIR:-}
+if [ "$agent" = codex ] || [ "$agent" = all ]; then
+ codex_skill_root=${BROWSERKIT_CODEX_SKILL_DIR:-${legacy_skill_root:-"${CODEX_HOME:-${HOME}/.codex}/skills/browserkit-cli"}}
+ install_skill "$codex_skill_root" Codex
+fi
+if [ "$agent" = pi ] || [ "$agent" = all ]; then
+ pi_skill_root=${BROWSERKIT_PI_SKILL_DIR:-${legacy_skill_root:-"${PI_CODING_AGENT_DIR:-${HOME}/.pi/agent}/skills/browserkit-cli"}}
+ install_skill "$pi_skill_root" "Pi Agent"
+fi
+
printf '已安装 browserkit: %s\n' "$bin_dir/browserkit"
-printf '已安装 Skill: %s\n' "$skill_root/SKILL.md"
case ":${PATH}:" in
*:"$bin_dir":*) ;;
*) printf '请将 %s 加入 PATH。\n' "$bin_dir" ;;
diff --git a/install_test.go b/install_test.go
new file mode 100644
index 0000000..8d72ad6
--- /dev/null
+++ b/install_test.go
@@ -0,0 +1,95 @@
+package browserkit
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestInstallScriptAgentTargets(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("install.sh requires a POSIX shell")
+ }
+ repo, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, test := range []struct {
+ name string
+ args []string
+ env []string
+ paths []string
+ }{
+ {name: "default codex", paths: []string{"codex/skills/browserkit-cli/SKILL.md"}},
+ {name: "pi", args: []string{"--agent", "pi"}, paths: []string{"pi/skills/browserkit-cli/SKILL.md"}},
+ {name: "all", args: []string{"--agent", "all"}, paths: []string{"codex/skills/browserkit-cli/SKILL.md", "pi/skills/browserkit-cli/SKILL.md"}},
+ {name: "environment default", env: []string{"BROWSERKIT_AGENT=pi"}, paths: []string{"pi/skills/browserkit-cli/SKILL.md"}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ root := t.TempDir()
+ fakeBin := filepath.Join(root, "fake-bin")
+ if err := os.MkdirAll(fakeBin, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ fakeGo := filepath.Join(fakeBin, "go")
+ if err := os.WriteFile(fakeGo, []byte("#!/bin/sh\nwhile [ \"$#\" -gt 0 ]; do\n if [ \"$1\" = -o ]; then mkdir -p \"$(dirname \"$2\")\"; : > \"$2\"; exit 0; fi\n shift\ndone\nexit 1\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("/bin/sh", append([]string{filepath.Join(repo, "install.sh")}, test.args...)...)
+ cmd.Env = append(os.Environ(),
+ "HOME="+root,
+ "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
+ "BROWSERKIT_AGENT=",
+ "BROWSERKIT_BIN_DIR="+filepath.Join(root, "bin"),
+ "BROWSERKIT_SKILL_DIR=",
+ "BROWSERKIT_CODEX_SKILL_DIR=",
+ "BROWSERKIT_PI_SKILL_DIR=",
+ "CODEX_HOME="+filepath.Join(root, "codex"),
+ "PI_CODING_AGENT_DIR="+filepath.Join(root, "pi"),
+ )
+ cmd.Env = append(cmd.Env, test.env...)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("install failed: %v\n%s", err, output)
+ }
+ for _, path := range test.paths {
+ contents, err := os.ReadFile(filepath.Join(root, path))
+ if err != nil {
+ t.Fatalf("missing %s: %v\n%s", path, err, output)
+ }
+ if !strings.Contains(string(contents), "name: browserkit-cli") {
+ t.Fatalf("invalid Skill at %s", path)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(root, "bin", "browserkit")); err != nil {
+ t.Fatalf("missing binary: %v", err)
+ }
+ })
+ }
+}
+
+func TestInstallScriptRejectsUnknownAgent(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("install.sh requires a POSIX shell")
+ }
+ cmd := exec.Command("/bin/sh", "./install.sh", "--agent", "unknown")
+ output, err := cmd.CombinedOutput()
+ if err == nil || !strings.Contains(string(output), "不支持的 Agent") {
+ t.Fatalf("expected agent validation error, err=%v output=%s", err, output)
+ }
+}
+
+func TestInstallScriptRejectsRemovedBothAlias(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("install.sh requires a POSIX shell")
+ }
+ cmd := exec.Command("/bin/sh", "./install.sh", "--agent", "both")
+ output, err := cmd.CombinedOutput()
+ if err == nil || !strings.Contains(string(output), "不支持的 Agent") {
+ t.Fatalf("expected removed alias to be rejected, err=%v output=%s", err, output)
+ }
+}
diff --git a/manager_browser_test.go b/manager_browser_test.go
index 28b9961..d7bfa62 100644
--- a/manager_browser_test.go
+++ b/manager_browser_test.go
@@ -1104,13 +1104,34 @@ func TestBrowserSnapshotReportsStableTabRevisionAndNewActiveTab(t *testing.T) {
if third.TabsRevision <= second.TabsRevision || !third.TabsChanged || third.ActivePageID == first.ActivePageID || third.PageID != third.ActivePageID || len(third.Tabs) != 2 {
t.Fatalf("新 Tab 应在 Snapshot 中被发现并成为 active: %+v", third)
}
+ stable := third
+ deadline := time.Now().Add(time.Second)
+ for activeTabStatus(stable) != "ready" && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ stable, err = manager.Snapshot(ctx, SnapshotRequest{SessionID: opened.SessionID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if activeTabStatus(stable) != "ready" {
+ t.Fatalf("新 Tab 没有进入 ready 状态: %+v", stable)
+ }
fourth, err := manager.Snapshot(ctx, SnapshotRequest{SessionID: opened.SessionID})
if err != nil {
t.Fatal(err)
}
- if fourth.TabsRevision != third.TabsRevision || fourth.TabsChanged {
- t.Fatalf("重复 Snapshot 不应重复报告 Tab 变化: third=%+v fourth=%+v", third, fourth)
+ if fourth.TabsRevision != stable.TabsRevision || fourth.TabsChanged {
+ t.Fatalf("Tab ready 后重复 Snapshot 不应重复报告变化: stable=%+v fourth=%+v", stable, fourth)
+ }
+}
+
+func activeTabStatus(snapshot SnapshotResponse) string {
+ for _, tab := range snapshot.Tabs {
+ if tab.PageID == snapshot.ActivePageID {
+ return tab.Status
+ }
}
+ return ""
}
func TestBrowserInteractionReleasesLockBeforeTabReconcile(t *testing.T) {
diff --git a/react/src/client.test.ts b/react/src/client.test.ts
index 60a9d16..230a3af 100644
--- a/react/src/client.test.ts
+++ b/react/src/client.test.ts
@@ -36,9 +36,17 @@ describe('browser client',()=>{
it('decodes frame identity separately from image bytes and rejects invalid headers',async()=>{
const header=new TextEncoder().encode(JSON.stringify({page_id:'page-a',generation:7,mime:'image/jpeg'}))
const bytes=new Uint8Array(4+header.length+3);new DataView(bytes.buffer).setUint32(0,header.length);bytes.set(header,4);bytes.set([1,2,3],4+header.length)
- const frame=await decodeFrame({arrayBuffer:async()=>bytes.buffer} as Blob)
+ const slices:Array<[number,number, string | undefined]>=[]
+ const data={size:bytes.byteLength,slice:(start=0,end=bytes.byteLength,type?:string)=>{
+ slices.push([start,end,type])
+ const part=bytes.slice(start,end)
+ return {size:part.byteLength,type:type||'',arrayBuffer:async()=>part.buffer} as Blob
+ }} as Blob
+ const frame=await decodeFrame(data)
expect(frame.pageID).toBe('page-a');expect(frame.generation).toBe(7);expect(frame.blob.size).toBe(3)
- await expect(decodeFrame({arrayBuffer:async()=>new ArrayBuffer(3)} as Blob)).rejects.toThrow('图帧头缺失')
+ expect(frame.blob.type).toBe('image/jpeg')
+ expect(slices).toEqual([[0,4,undefined],[4,4+header.length,undefined],[4+header.length,bytes.byteLength,'image/jpeg']])
+ await expect(decodeFrame({size:3} as Blob)).rejects.toThrow('图帧头缺失')
})
})
diff --git a/react/src/client.ts b/react/src/client.ts
index 07d2397..c04651a 100644
--- a/react/src/client.ts
+++ b/react/src/client.ts
@@ -191,14 +191,15 @@ export interface BrowserFrame {
generation: number;
}
export async function decodeFrame(data: Blob): Promise {
- const bytes = await data.arrayBuffer();
- if (bytes.byteLength < 4)
+ if (data.size < 4)
throw new Error('浏览器图帧头缺失');
- const length = new DataView(bytes).getUint32(0);
- if (length > 65536 || length + 4 > bytes.byteLength)
+ const prefix = await data.slice(0, 4).arrayBuffer();
+ const length = new DataView(prefix).getUint32(0);
+ if (length > 65536 || length + 4 > data.size)
throw new Error('浏览器图帧头无效');
- const header = JSON.parse(new TextDecoder().decode(bytes.slice(4, 4 + length)));
+ const headerBytes = await data.slice(4, 4 + length).arrayBuffer();
+ const header = JSON.parse(new TextDecoder().decode(headerBytes));
if (typeof header.page_id !== 'string' || !header.page_id || !Number.isSafeInteger(header.generation) || typeof header.mime !== 'string')
throw new Error('浏览器图帧身份无效');
- return { blob: new Blob([bytes.slice(4 + length)], { type: header.mime }), pageID: header.page_id, generation: header.generation };
+ return { blob: data.slice(4 + length, data.size, header.mime), pageID: header.page_id, generation: header.generation };
}
diff --git a/session.go b/session.go
index 1904650..1118f61 100644
--- a/session.go
+++ b/session.go
@@ -68,6 +68,13 @@ type TabSession interface {
CloseTab(context.Context, string) error
}
+// ActiveTabSession 可在不为每个 target 创建完整 Page 绑定的情况下探测激活 Tab。
+// Attach 会优先使用该可选能力,且只为最终选中的 target 调用 PageForTab。
+type ActiveTabSession interface {
+ TabSession
+ ActiveTab(context.Context, []TabInfo) (string, error)
+}
+
// ViewportTabSession 是可为已有 Tab 设置页面视口的可选能力。
type ViewportTabSession interface {
TabSession
@@ -409,6 +416,98 @@ func (session *rodSession) ListTabs(ctx context.Context) ([]TabInfo, error) {
return tabs, nil
}
+// ActiveTab 使用短生命周期 CDP session 探测页面焦点;探测完成后立即 detach,
+// 避免 PageForTab 为所有候选页安装观察器、初始化输入设备并长期缓存 Page。
+func (session *rodSession) ActiveTab(ctx context.Context, listed []TabInfo) (string, error) {
+ if session == nil || session.closed.Load() || session.browser == nil {
+ return "", newError("session_closed", "浏览器 session 已关闭", "重新调用 browserOpen 创建 session", true)
+ }
+ if err := contextErr(ctx); err != nil {
+ return "", err
+ }
+ operationCtx, operationCancel, err := session.ctx(ctx)
+ if err != nil {
+ return "", err
+ }
+ defer operationCancel()
+ if err := lockInteraction(operationCtx, &session.attachMu); err != nil {
+ return "", err
+ }
+ defer session.attachMu.Unlock()
+
+ visibleTarget := ""
+ visibleCount := 0
+ for _, tab := range listed {
+ if tab.Type != "page" {
+ continue
+ }
+ state, err := session.probeTabState(operationCtx, proto.TargetTargetID(tab.TargetID))
+ if err != nil {
+ return "", fmt.Errorf("读取 Tab 状态: %w", err)
+ }
+ if state.Focused {
+ return tab.TargetID, nil
+ }
+ if state.Visible {
+ visibleTarget = tab.TargetID
+ visibleCount++
+ }
+ }
+ if visibleCount == 1 {
+ return visibleTarget, nil
+ }
+ return "", errors.New("无法唯一确定激活 Tab;请切回目标网页并保持浏览器焦点后重试")
+}
+
+type tabFocusState struct {
+ Focused bool `json:"focused"`
+ Visible bool `json:"visible"`
+}
+
+type targetSessionClient struct {
+ ctx context.Context
+ browser *rod.Browser
+ sessionID proto.TargetSessionID
+}
+
+func (client targetSessionClient) GetContext() context.Context { return client.ctx }
+func (client targetSessionClient) GetSessionID() proto.TargetSessionID { return client.sessionID }
+func (client targetSessionClient) Call(ctx context.Context, sessionID, method string, params any) ([]byte, error) {
+ return client.browser.Call(ctx, sessionID, method, params)
+}
+
+func (session *rodSession) probeTabState(ctx context.Context, targetID proto.TargetTargetID) (state tabFocusState, err error) {
+ attached, err := (proto.TargetAttachToTarget{TargetID: targetID, Flatten: true}).Call(session.browser.Context(ctx))
+ if err != nil {
+ return tabFocusState{}, err
+ }
+ defer func() {
+ cleanupCtx, cleanupCancel, cleanupErr := session.controlCtx(session.browser.GetContext(), 5*time.Second)
+ if cleanupErr == nil {
+ cleanupErr = (proto.TargetDetachFromTarget{SessionID: attached.SessionID}).Call(session.browser.Context(cleanupCtx))
+ cleanupCancel()
+ }
+ if err == nil && cleanupErr != nil {
+ err = fmt.Errorf("释放 Tab 探测 session: %w", cleanupErr)
+ }
+ }()
+ probe := targetSessionClient{ctx: ctx, browser: session.browser, sessionID: attached.SessionID}
+ result, err := (proto.RuntimeEvaluate{
+ Expression: `({focused: document.hasFocus(), visible: document.visibilityState === "visible"})`,
+ ReturnByValue: true,
+ }).Call(probe)
+ if err != nil {
+ return tabFocusState{}, err
+ }
+ if result.ExceptionDetails != nil || result.Result == nil {
+ return tabFocusState{}, errors.New("页面未返回激活状态")
+ }
+ if err := result.Result.Value.Unmarshal(&state); err != nil {
+ return tabFocusState{}, fmt.Errorf("解析激活页状态: %w", err)
+ }
+ return state, nil
+}
+
// PageForTab 绑定一个已经存在的网页 target,并安装与 NewPage 相同的页面状态观察器。
func (session *rodSession) PageForTab(ctx context.Context, targetID string) (Page, error) {
if session == nil || session.closed.Load() || session.browser == nil {
diff --git a/skills/browserkit-cli/SKILL.md b/skills/browserkit-cli/SKILL.md
index cf99eb6..4f0d79b 100644
--- a/skills/browserkit-cli/SKILL.md
+++ b/skills/browserkit-cli/SKILL.md
@@ -5,7 +5,7 @@ description: 通过 BrowserKit CLI 操作 Chrome 网页,支持连接已有浏
# BrowserKit
-仓库根目录的 `install.sh` 同时安装 client 与本 Skill。
+仓库根目录的 `install.sh` 同时安装 client 与本 Skill。Codex 使用默认安装;Pi Agent 使用 `./install.sh --agent pi`,并可通过 `/skill:browserkit-cli` 显式加载。
根据用户指定的浏览器选择模式;连接失败时检查 CDP 地址和目标页,不自动切换到另一个浏览器。
## 启动