From f613250a9dc401b8360e2ce1a5e606a73cd20a12 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 11 May 2026 03:48:33 +0800 Subject: [PATCH] refactor(browser): replace workspaces with sessions --- CHANGELOG.md | 10 + README.md | 16 +- README.zh-CN.md | 16 +- cli-manifest.json | 2 +- clis/notebooklm/current.js | 2 +- clis/notebooklm/get.js | 2 +- clis/notebooklm/history.js | 2 +- clis/notebooklm/note-list.js | 2 +- clis/notebooklm/notes-get.js | 2 +- clis/notebooklm/open.js | 4 +- clis/notebooklm/open.test.js | 2 +- clis/notebooklm/source-fulltext.js | 2 +- clis/notebooklm/source-get.js | 2 +- clis/notebooklm/source-guide.js | 2 +- clis/notebooklm/source-list.js | 2 +- clis/notebooklm/summary.js | 2 +- docs/adapters/browser/notebooklm.md | 6 +- docs/guide/browser-bridge.md | 20 +- docs/zh/guide/browser-bridge.md | 20 +- extension/dist/background.js | 494 ++++++++------- extension/manifest.json | 2 +- extension/package-lock.json | 4 +- extension/package.json | 2 +- extension/src/background.test.ts | 367 ++++++------ extension/src/background.ts | 567 +++++++++--------- extension/src/protocol.ts | 14 +- .../references/api-discovery.md | 2 +- skills/opencli-browser/SKILL.md | 99 ++- skills/opencli-usage/SKILL.md | 2 +- src/browser/bridge.ts | 5 +- src/browser/cdp.ts | 2 +- src/browser/daemon-client.ts | 13 +- src/browser/network-cache.test.ts | 8 +- src/browser/network-cache.ts | 18 +- src/browser/page.test.ts | 72 ++- src/browser/page.ts | 32 +- src/cli.test.ts | 381 ++++++------ src/cli.ts | 210 ++----- src/doctor.test.ts | 10 +- src/doctor.ts | 2 +- src/execution.test.ts | 24 +- src/execution.ts | 8 +- src/observation/artifact.test.ts | 6 +- src/observation/artifact.ts | 2 +- src/observation/events.ts | 2 +- src/observation/manager.test.ts | 6 +- src/observation/manager.ts | 2 +- src/registry.ts | 4 +- src/runtime.ts | 8 +- src/types.ts | 6 +- tests/e2e/browser-ax-chrome.test.ts | 20 +- tests/e2e/browser-tabs.test.ts | 29 +- 52 files changed, 1210 insertions(+), 1329 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cada30d..0cf9783da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### ⚠ BREAKING CHANGES + +* **browser session model** — replace the browser-facing `--workspace` model with explicit `--session ` on `opencli browser *`. Browser commands now require a session name, `browser bind`/`unbind` use `--session`, and bind no longer accepts `--domain`, `--path-prefix`, or `--allow-navigate-bound`. Browser primitives keep their session tab by design; the browser namespace no longer exposes `--keep-tab`. Adapter `--keep-tab` and `browserSession.reuse: 'site'` are unchanged. + +### Internal + +* **extension 1.0.11** — switch Browser Bridge lease routing from user-facing workspaces to explicit browser sessions. + ## [1.7.16](https://github.com/jackwener/opencli/compare/v1.7.15...v1.7.16) (2026-05-11) Extension bumped to 1.0.10 (rename adapter-owned tab group `OpenCLI Automation` → `OpenCLI Adapter`). Performance and stability sweep across browser-backed adapters; new external CLI integrations (tg-cli, discord-cli, wx-cli). diff --git a/README.md b/README.md index a3d8e59d7..f04acbca1 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ The agent handles all the `opencli browser` commands internally — you just des Available browser commands include `open`, `state`, `click`, `type`, `fill`, `select`, `keys`, `wait`, `get`, `find`, `extract`, `frames`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`. -`opencli browser open ` and `opencli browser tab new [url]` both return a target ID. Use `opencli browser tab list` to inspect the target IDs of tabs that already exist, then pass `--tab ` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select ` promotes that tab to the default target for later untargeted `opencli browser ...` commands. +`opencli browser` commands require `--session `. `opencli browser --session work open ` and `opencli browser --session work tab new [url]` both return a target ID. Use `opencli browser --session work tab list` to inspect target IDs, then pass `--tab ` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select ` promotes that tab to the default target for later untargeted commands in the same session. ## Core Concepts @@ -160,7 +160,7 @@ Available browser commands include `open`, `state`, `click`, `type`, `fill`, `se `opencli browser` commands are the low-level primitives that AI Agents use to operate websites. You don't run these manually — instead, install the `opencli-adapter-author` skill into your AI agent, describe what you want in natural language, and the agent handles the browser operations. -For example, tell your agent: *"Help me check my Xiaohongshu notifications"* — the agent will use `opencli browser open`, `state`, `click`, etc. under the hood. +For example, tell your agent: *"Help me check my Xiaohongshu notifications"* — the agent will use `opencli browser --session open`, `state`, `click`, etc. under the hood. ### Built-in adapters: stable commands @@ -174,7 +174,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil 2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback. 3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`. 4. Decode response fields and design output columns. -5. `opencli browser analyze ` for one-shot recon, then `opencli browser init /` → write adapter → `opencli browser verify /`. +5. `opencli browser --session recon analyze ` for one-shot recon, then `opencli browser --session recon init /` → write adapter → `opencli browser --session recon verify /`. 6. Persist site knowledge to `~/.opencli/sites//` so the next adapter for the same site is faster. ### CLI Hub and desktop adapters @@ -199,7 +199,7 @@ OpenCLI is not only for websites. It can also: | `OPENCLI_DAEMON_PORT` | `19825` | HTTP port for the daemon-extension bridge | | `OPENCLI_PROFILE` | — | Browser Bridge profile alias/contextId to use when multiple Chrome profiles are connected | | `OPENCLI_WINDOW` | command default | Set to `foreground` or `background` to override Browser Bridge window placement. Browser-backed commands also accept `--window `. | -| `OPENCLI_KEEP_TAB` | command default | Set to `true` or `false` to keep or release the browser tab lease after a browser-backed command. Browser-backed commands also accept `--keep-tab `. | +| `OPENCLI_KEEP_TAB` | command default | Set to `true` or `false` to keep or release the browser tab lease after a browser-backed adapter command. Browser-backed adapter commands also accept `--keep-tab `. | | `OPENCLI_BROWSER_REUSE` | adapter default | Set to `none` or `site` to override adapter browser tab reuse. The `--reuse ` flag sets this. | | `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection | | `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command | @@ -208,7 +208,7 @@ OpenCLI is not only for websites. It can also: | `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) | | `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output | -`opencli browser *` uses a foreground browser window and keeps its tab lease by default. Browser-backed adapters use a background automation window and release one-shot tab leases by default. Some interactive adapters default to `--reuse site`, which also keeps the site tab lease; pass `--reuse none` for a one-shot tab. +`opencli browser *` requires an explicit `--session `, uses a foreground browser window by default, and keeps that session's tab lease until `browser --session close` or idle cleanup. Browser-backed adapters use a background adapter window and release one-shot tab leases by default. Some interactive adapters default to `--reuse site`, which keeps the site tab lease for continuity; pass `--reuse none` for a one-shot tab. ## Update @@ -409,10 +409,10 @@ See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin. Before writing any adapter code, read the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md). It takes you end-to-end: - Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming). -- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback. +- Discover the right endpoint via `opencli browser --session network`, `eval`, or the interceptor fallback. - Decide auth strategy (`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`). -- Run `opencli browser analyze ` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`. -- Verify with `opencli browser verify /` before shipping. +- Run `opencli browser --session recon analyze ` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser --session recon init`. +- Verify with `opencli browser --session recon verify /` before shipping. For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis//.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites//` so the next adapter for the same site starts from context instead of zero. diff --git a/README.zh-CN.md b/README.zh-CN.md index 12fb9f285..303346dfa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -136,7 +136,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 `browser` 可用命令包括:`open`、`state`、`click`、`type`、`fill`、`select`、`keys`、`wait`、`get`、`find`、`extract`、`frames`、`screenshot`、`scroll`、`back`、`eval`、`network`、`tab list`、`tab new`、`tab select`、`tab close`、`init`、`verify`、`close`。 -`opencli browser open ` 和 `opencli browser tab new [url]` 都会返回 target ID。`opencli browser tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab ` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select `,才会把该 tab 设为后续未指定 target 的 `opencli browser ...` 命令的默认目标。 +`opencli browser` 命令必须显式传 `--session `。`opencli browser --session work open ` 和 `opencli browser --session work tab new [url]` 都会返回 target ID。`opencli browser --session work tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab ` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select `,才会把该 tab 设为同一 session 后续未指定 target 的默认目标。 ## 核心概念 @@ -144,7 +144,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 `opencli browser` 命令是 AI Agent 操作网站的底层原语。你不需要手动运行这些命令——把 `opencli-adapter-author` skill 安装到你的 AI Agent 中,用自然语言描述你想做的事,Agent 会自动处理浏览器操作。 -比如你告诉 Agent:*"帮我看看小红书的通知"*——Agent 会在底层调用 `opencli browser open`、`state`、`click` 等命令。 +比如你告诉 Agent:*"帮我看看小红书的通知"*——Agent 会在底层调用 `opencli browser --session open`、`state`、`click` 等命令。 ### 内置适配器:稳定命令 @@ -158,7 +158,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底 3. 定认证策略——`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL` 4. 字段解码 + 设计输出列 -5. `opencli browser analyze ` 一步侦察,再 `opencli browser init /` → 写适配器 → `opencli browser verify /` +5. `opencli browser --session recon analyze ` 一步侦察,再 `opencli browser --session recon init /` → 写适配器 → `opencli browser --session recon verify /` 6. 把站点知识沉到 `~/.opencli/sites//`,下次写同站点的其他命令直接吃缓存 ### CLI 枢纽与桌面端适配器 @@ -182,7 +182,7 @@ OpenCLI 不只是网站 CLI,还可以: |------|--------|------| | `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 | | `OPENCLI_WINDOW` | 命令默认值 | 设为 `foreground` 或 `background` 来覆盖 Browser Bridge 窗口位置。浏览器型命令也支持 `--window ` | -| `OPENCLI_KEEP_TAB` | 命令默认值 | 设为 `true` 或 `false` 来控制浏览器型命令结束后是否保留 tab lease。浏览器型命令也支持 `--keep-tab ` | +| `OPENCLI_KEEP_TAB` | 命令默认值 | 设为 `true` 或 `false` 来控制浏览器型 adapter 命令结束后是否保留 tab lease。浏览器型 adapter 命令也支持 `--keep-tab ` | | `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) | | `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) | | `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 | @@ -190,7 +190,7 @@ OpenCLI 不只是网站 CLI,还可以: | `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) | | `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 | -`opencli browser *` 默认使用前台窗口并保留 tab lease,直到你手动执行 `opencli browser close` 或等空闲超时。浏览器型 adapter 默认使用后台 automation 窗口并在命令结束后释放 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`。 +`opencli browser *` 必须显式传 `--session `,默认使用前台窗口,并保留该 session 的 tab lease,直到你手动执行 `opencli browser --session close` 或等空闲超时。浏览器型 adapter 默认使用后台 adapter 窗口并在命令结束后释放一次性 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`。 ## 更新 @@ -508,10 +508,10 @@ opencli plugin uninstall my-tool # 卸载 在动代码前,先读 [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md)。它把整个流程串起来: - 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming) -- 用 `opencli browser network`、`eval`、interceptor 等找到目标 endpoint +- 用 `opencli browser --session network`、`eval`、interceptor 等找到目标 endpoint - 定认证策略(`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`) -- 先用 `opencli browser analyze ` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架 -- 交付前用 `opencli browser verify /` 验证 +- 先用 `opencli browser --session recon analyze ` 一步侦察,再字段解码、设计 columns、`opencli browser --session recon init` 生成骨架 +- 交付前用 `opencli browser --session recon verify /` 验证 在仓库外写的私有适配器放到 `~/.opencli/clis//.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites//`,下次写同站点的其他命令可以直接复用。 diff --git a/cli-manifest.json b/cli-manifest.json index 609254dd3..690d31623 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -15536,7 +15536,7 @@ "aliases": [ "select" ], - "description": "Open one NotebookLM notebook in the automation workspace by id or URL", + "description": "Open one NotebookLM notebook in the adapter session by id or URL", "access": "read", "domain": "notebooklm.google.com", "strategy": "cookie", diff --git a/clis/notebooklm/current.js b/clis/notebooklm/current.js index 1f66727ce..de830dd71 100644 --- a/clis/notebooklm/current.js +++ b/clis/notebooklm/current.js @@ -17,7 +17,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm current', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm current', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const current = await readCurrentNotebooklm(page); if (!current) { diff --git a/clis/notebooklm/get.js b/clis/notebooklm/get.js index 2955ce6d1..a439332a6 100644 --- a/clis/notebooklm/get.js +++ b/clis/notebooklm/get.js @@ -18,7 +18,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm get', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm get', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const rpcRow = await getNotebooklmDetailViaRpc(page).catch(() => null); if (rpcRow) diff --git a/clis/notebooklm/history.js b/clis/notebooklm/history.js index ce29faf10..948cb9e20 100644 --- a/clis/notebooklm/history.js +++ b/clis/notebooklm/history.js @@ -17,7 +17,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm history', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm history', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const rows = await listNotebooklmHistoryViaRpc(page); return rows; diff --git a/clis/notebooklm/note-list.js b/clis/notebooklm/note-list.js index 8a6799d9c..62c04efe6 100644 --- a/clis/notebooklm/note-list.js +++ b/clis/notebooklm/note-list.js @@ -18,7 +18,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm note-list', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm note-list', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const rows = await listNotebooklmNotesFromPage(page); if (rows.length > 0) diff --git a/clis/notebooklm/notes-get.js b/clis/notebooklm/notes-get.js index e086ff047..d3e4455f8 100644 --- a/clis/notebooklm/notes-get.js +++ b/clis/notebooklm/notes-get.js @@ -31,7 +31,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm notes-get', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm notes-get', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const query = typeof kwargs.note === 'string' ? kwargs.note : String(kwargs.note ?? ''); const visible = await readNotebooklmVisibleNoteFromPage(page); diff --git a/clis/notebooklm/open.js b/clis/notebooklm/open.js index 4296dea64..3ed20ecd7 100644 --- a/clis/notebooklm/open.js +++ b/clis/notebooklm/open.js @@ -7,7 +7,7 @@ cli({ name: 'open', access: 'read', aliases: ['select'], - description: 'Open one NotebookLM notebook in the automation workspace by id or URL', + description: 'Open one NotebookLM notebook in the adapter session by id or URL', domain: NOTEBOOKLM_DOMAIN, strategy: Strategy.COOKIE, browser: true, @@ -28,7 +28,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new CliError('NOTEBOOKLM_OPEN_FAILED', `NotebookLM notebook "${notebookId}" did not open in the automation workspace`, 'Run `opencli notebooklm list -f json` first and pass a valid notebook id.'); + throw new CliError('NOTEBOOKLM_OPEN_FAILED', `NotebookLM notebook "${notebookId}" did not open in the adapter session`, 'Run `opencli notebooklm list -f json` first and pass a valid notebook id.'); } if (state.notebookId !== notebookId) { console.warn(`[notebooklm open] expected notebook "${notebookId}" but page reports "${state.notebookId}"; continuing`); diff --git a/clis/notebooklm/open.test.js b/clis/notebooklm/open.test.js index c2df9ea40..eb20bdd23 100644 --- a/clis/notebooklm/open.test.js +++ b/clis/notebooklm/open.test.js @@ -38,7 +38,7 @@ describe('notebooklm open', () => { source: 'current-page', }); }); - it('opens a notebook by id in the automation workspace', async () => { + it('opens a notebook by id in the adapter session', async () => { const page = { goto: vi.fn(async () => { }), wait: vi.fn(async () => { }), diff --git a/clis/notebooklm/source-fulltext.js b/clis/notebooklm/source-fulltext.js index ecde440a9..38ecb7da1 100644 --- a/clis/notebooklm/source-fulltext.js +++ b/clis/notebooklm/source-fulltext.js @@ -24,7 +24,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm source-fulltext', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm source-fulltext', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []); const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page); diff --git a/clis/notebooklm/source-get.js b/clis/notebooklm/source-get.js index 5aaed3290..e50da04cb 100644 --- a/clis/notebooklm/source-get.js +++ b/clis/notebooklm/source-get.js @@ -24,7 +24,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm source-get', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm source-get', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []); const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page); diff --git a/clis/notebooklm/source-guide.js b/clis/notebooklm/source-guide.js index 5ed76f80a..ba214c924 100644 --- a/clis/notebooklm/source-guide.js +++ b/clis/notebooklm/source-guide.js @@ -24,7 +24,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm source-guide', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm source-guide', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []); const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page); diff --git a/clis/notebooklm/source-list.js b/clis/notebooklm/source-list.js index f8765de41..4c5499eb4 100644 --- a/clis/notebooklm/source-list.js +++ b/clis/notebooklm/source-list.js @@ -17,7 +17,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm source-list', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm source-list', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []); if (rpcRows.length > 0) diff --git a/clis/notebooklm/summary.js b/clis/notebooklm/summary.js index 407a3005c..d01d82f8a 100644 --- a/clis/notebooklm/summary.js +++ b/clis/notebooklm/summary.js @@ -17,7 +17,7 @@ cli({ await requireNotebooklmSession(page); const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook') { - throw new EmptyResultError('opencli notebooklm summary', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open ` first.'); + throw new EmptyResultError('opencli notebooklm summary', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open ` first.'); } const domSummary = await readNotebooklmSummaryFromPage(page); if (domSummary) diff --git a/docs/adapters/browser/notebooklm.md b/docs/adapters/browser/notebooklm.md index 5e26f71ee..e0f3a52cc 100644 --- a/docs/adapters/browser/notebooklm.md +++ b/docs/adapters/browser/notebooklm.md @@ -8,8 +8,8 @@ |---------|-------------| | `opencli notebooklm status` | Check whether NotebookLM is reachable in the current Chrome session | | `opencli notebooklm list` | List notebooks visible from the NotebookLM home page | -| `opencli notebooklm open ` | Open one notebook in the NotebookLM automation workspace by id or URL | -| `opencli notebooklm current` | Show metadata for the currently opened notebook in the automation workspace | +| `opencli notebooklm open ` | Open one notebook in the NotebookLM adapter session by id or URL | +| `opencli notebooklm current` | Show metadata for the currently opened notebook in the adapter session | | `opencli notebooklm get` | Get richer metadata for the current notebook | | `opencli notebooklm source-list` | List sources in the current notebook | | `opencli notebooklm source-get ` | Resolve one source in the current notebook by id or title | @@ -64,6 +64,6 @@ opencli notebooklm summary -f json ## Notes -- Notebook-oriented commands run in OpenCLI's owned NotebookLM automation workspace/window. Use `opencli notebooklm open ` first to choose the current notebook for follow-up commands. +- Notebook-oriented commands run in OpenCLI's owned NotebookLM adapter session/window. Use `opencli notebooklm open ` first to choose the current notebook for follow-up commands. - `list`, `get`, `source-list`, `history`, `source-fulltext`, and `source-guide` prefer NotebookLM RPC paths and fall back only when the richer path is unavailable. - `notes-get` currently reads note content only from the visible Studio note editor; if the note is listed but not open, open it in NotebookLM first and then retry. diff --git a/docs/guide/browser-bridge.md b/docs/guide/browser-bridge.md index 6c4297800..7d701d10a 100644 --- a/docs/guide/browser-bridge.md +++ b/docs/guide/browser-bridge.md @@ -27,22 +27,22 @@ opencli doctor # Check extension + daemon connectivity ## Tab Targeting -Browser commands run inside the shared `browser:default` workspace unless you explicitly choose another tab target. +Browser commands require an explicit `--session `. Use the same session name for a multi-step flow, and use different names to isolate parallel work. ```bash -opencli browser open https://www.baidu.com/ -opencli browser tab list -opencli browser tab new https://www.baidu.com/ -opencli browser eval --tab 'document.title' -opencli browser tab select -opencli browser get title -opencli browser tab close +opencli browser --session baidu open https://www.baidu.com/ +opencli browser --session baidu tab list +opencli browser --session baidu tab new https://www.baidu.com/ +opencli browser --session baidu eval --tab 'document.title' +opencli browser --session baidu tab select +opencli browser --session baidu get title +opencli browser --session baidu tab close ``` Key rules: -- `opencli browser open ` and `opencli browser tab new [url]` return a `targetId`. -- `opencli browser tab list` prints the `targetId` values of tabs that already exist. +- `opencli browser --session open ` and `opencli browser --session tab new [url]` return a `targetId`. +- `opencli browser --session tab list` prints the `targetId` values of tabs that already exist. - `--tab ` routes a single browser command to that specific tab. - `tab new` creates a new tab but does not change the default browser target. - `tab select ` makes that tab the default target for later untargeted `opencli browser ...` commands. diff --git a/docs/zh/guide/browser-bridge.md b/docs/zh/guide/browser-bridge.md index aaadbf3cb..3d7180a5a 100644 --- a/docs/zh/guide/browser-bridge.md +++ b/docs/zh/guide/browser-bridge.md @@ -25,22 +25,22 @@ opencli doctor # 检查扩展 + 守护进程连接 ## 多 Tab 定位 -浏览器命令默认运行在共享的 `browser:default` workspace 中;如果需要操作指定 tab,可以显式传目标 target。 +浏览器命令必须显式传 `--session `。同一个多步骤流程使用同一个 session;并行任务使用不同 session 隔离。 ```bash -opencli browser open https://www.baidu.com/ -opencli browser tab list -opencli browser tab new https://www.baidu.com/ -opencli browser eval --tab 'document.title' -opencli browser tab select -opencli browser get title -opencli browser tab close +opencli browser --session baidu open https://www.baidu.com/ +opencli browser --session baidu tab list +opencli browser --session baidu tab new https://www.baidu.com/ +opencli browser --session baidu eval --tab 'document.title' +opencli browser --session baidu tab select +opencli browser --session baidu get title +opencli browser --session baidu tab close ``` 规则如下: -- `opencli browser open ` 和 `opencli browser tab new [url]` 都会返回 `targetId`。 -- `opencli browser tab list` 会打印当前已存在 tab 的 `targetId`。 +- `opencli browser --session open ` 和 `opencli browser --session tab new [url]` 都会返回 `targetId`。 +- `opencli browser --session tab list` 会打印当前已存在 tab 的 `targetId`。 - `--tab ` 会把单条 browser 命令路由到对应 tab。 - `tab new` 只会新建 tab,不会改变默认浏览器目标。 - `tab select ` 会把该 tab 设为后续未显式指定 target 的 `opencli browser ...` 命令默认目标。 diff --git a/extension/dist/background.js b/extension/dist/background.js index d8a8124c4..d32df7962 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -773,38 +773,63 @@ class CommandFailure extends Error { this.name = "CommandFailure"; } } -const workspaceTimeoutOverrides = /* @__PURE__ */ new Map(); -const workspaceWindowModeOverrides = /* @__PURE__ */ new Map(); -function getIdleTimeout(workspace) { - if (workspace.startsWith("bound:")) return IDLE_TIMEOUT_NONE; - const override = workspaceTimeoutOverrides.get(workspace); - if (override !== void 0) return override; - if (workspace.startsWith("browser:") || workspace.startsWith("operate:")) { - return IDLE_TIMEOUT_INTERACTIVE; +const sessionTimeoutOverrides = /* @__PURE__ */ new Map(); +const sessionWindowModeOverrides = /* @__PURE__ */ new Map(); +const LEASE_KEY_SEPARATOR = "\0"; +function getLeaseKey(session, surface) { + return `${surface}${LEASE_KEY_SEPARATOR}${encodeURIComponent(session)}`; +} +function getSessionName(session) { + const raw = session?.trim(); + if (!raw) throw new CommandFailure( + "session_required", + "Browser session is required.", + "Pass --session with opencli browser commands." + ); + return raw.includes(LEASE_KEY_SEPARATOR) ? getSessionFromKey(raw) : raw; +} +function getCommandSurface(cmd) { + if (typeof cmd.session === "string" && cmd.session.includes(LEASE_KEY_SEPARATOR)) { + return getSurfaceFromKey(cmd.session); + } + return cmd.surface === "adapter" ? "adapter" : "browser"; +} +function getSurfaceFromKey(key) { + return key.split(LEASE_KEY_SEPARATOR, 1)[0] === "adapter" ? "adapter" : "browser"; +} +function getSessionFromKey(key) { + const idx = key.indexOf(LEASE_KEY_SEPARATOR); + if (idx === -1) return key; + try { + return decodeURIComponent(key.slice(idx + 1)); + } catch { + return key.slice(idx + 1); } - return IDLE_TIMEOUT_DEFAULT; } -function getWorkspaceKey(workspace) { - return workspace?.trim() || "default"; +function getIdleTimeout(key) { + const session = automationSessions.get(key); + if (session?.kind === "bound") return IDLE_TIMEOUT_NONE; + const override = sessionTimeoutOverrides.get(key); + if (override !== void 0) return override; + return getSurfaceFromKey(key) === "browser" ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT; } -function getLeaseLifecycle(workspace) { - if (workspace.startsWith("bound:")) return "pinned"; - if (workspace.startsWith("browser:") || workspace.startsWith("operate:")) return "persistent"; - return "ephemeral"; +function getLeaseLifecycle(key, kind) { + if (kind === "bound") return "pinned"; + return getSurfaceFromKey(key) === "browser" ? "persistent" : "ephemeral"; } -function getOwnedWindowRole(workspace) { - return workspace.startsWith("browser:") || workspace.startsWith("operate:") ? "interactive" : "automation"; +function getOwnedWindowRole(key) { + return getSurfaceFromKey(key) === "browser" ? "interactive" : "automation"; } -function getWindowRole(workspace, ownership) { - return ownership === "borrowed" ? "borrowed-user" : getOwnedWindowRole(workspace); +function getWindowRole(key, ownership) { + return ownership === "borrowed" ? "borrowed-user" : getOwnedWindowRole(key); } -function getWindowMode(workspace) { - return workspaceWindowModeOverrides.get(workspace) ?? (getOwnedWindowRole(workspace) === "interactive" ? "foreground" : "background"); +function getWindowMode(key) { + return sessionWindowModeOverrides.get(key) ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background"); } -function makeAlarmName(workspace) { - return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(workspace)}`; +function makeAlarmName(leaseKey) { + return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`; } -function workspaceFromAlarmName(name) { +function leaseKeyFromAlarmName(name) { if (!name.startsWith(LEASE_IDLE_ALARM_PREFIX)) return null; try { return decodeURIComponent(name.slice(LEASE_IDLE_ALARM_PREFIX.length)); @@ -817,14 +842,14 @@ function withLeaseMutation(fn) { leaseMutationQueue = run.then(() => void 0, () => void 0); return run; } -function makeSession(workspace, session) { +function makeSession(key, session) { const ownership = session.owned ? "owned" : "borrowed"; return { ...session, contextId: currentContextId, ownership, - lifecycle: getLeaseLifecycle(workspace), - windowRole: getWindowRole(workspace, ownership) + lifecycle: getLeaseLifecycle(key, session.kind), + windowRole: getWindowRole(key, ownership) }; } function emptyRegistry() { @@ -879,8 +904,11 @@ async function writeRegistry(registry) { } async function persistRuntimeState() { const leases = {}; - for (const [workspace, session] of automationSessions.entries()) { - leases[workspace] = { + for (const [leaseKey, session] of automationSessions.entries()) { + leases[leaseKey] = { + session: session.session, + surface: session.surface, + kind: session.kind, windowId: session.windowId, owned: session.owned, preferredTabId: session.preferredTabId, @@ -908,8 +936,8 @@ async function persistRuntimeState() { leases }); } -function scheduleIdleAlarm(workspace, timeout) { - const alarmName = makeAlarmName(workspace); +function scheduleIdleAlarm(leaseKey, timeout) { + const alarmName = makeAlarmName(leaseKey); try { if (timeout > 0) { chrome.alarms?.create?.(alarmName, { when: Date.now() + timeout }); @@ -926,21 +954,21 @@ async function safeDetach(tabId) { } catch { } } -async function removeWorkspaceSession(workspace) { - const existing = automationSessions.get(workspace); +async function removeLeaseSession(leaseKey) { + const existing = automationSessions.get(leaseKey); if (existing?.idleTimer) clearTimeout(existing.idleTimer); - automationSessions.delete(workspace); - workspaceTimeoutOverrides.delete(workspace); - workspaceWindowModeOverrides.delete(workspace); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); } -function resetWindowIdleTimer(workspace) { - const session = automationSessions.get(workspace); +function resetWindowIdleTimer(leaseKey) { + const session = automationSessions.get(leaseKey); if (!session) return; if (session.idleTimer) clearTimeout(session.idleTimer); - const timeout = getIdleTimeout(workspace); - scheduleIdleAlarm(workspace, timeout); + const timeout = getIdleTimeout(leaseKey); + scheduleIdleAlarm(leaseKey, timeout); if (timeout <= 0) { session.idleTimer = null; session.idleDeadlineAt = 0; @@ -950,7 +978,7 @@ function resetWindowIdleTimer(workspace) { session.idleDeadlineAt = Date.now() + timeout; void persistRuntimeState(); session.idleTimer = setTimeout(async () => { - await releaseWorkspaceLease(workspace, "idle timeout"); + await releaseLease(leaseKey, "idle timeout"); }, timeout); } async function getOwnedContainerGroupId(role, windowId) { @@ -1075,13 +1103,13 @@ function initialTabIsAvailable(tabId) { } return true; } -async function createOwnedTabLease(workspace, initialUrl) { - return withLeaseMutation(() => createOwnedTabLeaseUnlocked(workspace, initialUrl)); +async function createOwnedTabLease(leaseKey, initialUrl) { + return withLeaseMutation(() => createOwnedTabLeaseUnlocked(leaseKey, initialUrl)); } -async function createOwnedTabLeaseUnlocked(workspace, initialUrl) { +async function createOwnedTabLeaseUnlocked(leaseKey, initialUrl) { const targetUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; - const role = getOwnedWindowRole(workspace); - const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(workspace)); + const role = getOwnedWindowRole(leaseKey); + const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(leaseKey)); let tab; if (initialTabIsAvailable(initialTabId)) { tab = await chrome.tabs.get(initialTabId); @@ -1095,29 +1123,25 @@ async function createOwnedTabLeaseUnlocked(workspace, initialUrl) { } if (!tab.id) throw new Error("Failed to create tab lease in automation container"); await ensureOwnedContainerTabGroup(role, windowId, [tab.id]); - setWorkspaceSession(workspace, { + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "owned", windowId, owned: true, preferredTabId: tab.id }); - resetWindowIdleTimer(workspace); + resetWindowIdleTimer(leaseKey); return { tabId: tab.id, tab }; } -async function getAutomationWindow(workspace, initialUrl) { - if (workspace.startsWith("bound:") && !automationSessions.has(workspace)) { - throw new CommandFailure( - "bound_session_missing", - `Bound workspace "${workspace}" is not attached to a tab. Run "opencli browser bind --workspace ${workspace}" first.`, - "Run bind again, then retry the browser command." - ); - } - const existing = automationSessions.get(workspace); +async function getAutomationWindow(leaseKey, initialUrl) { + const existing = automationSessions.get(leaseKey); if (existing) { if (!existing.owned) { throw new CommandFailure( "bound_window_operation_blocked", - `Workspace "${workspace}" is bound to a user tab and does not own an automation tab lease.`, - "Use commands that operate on the bound tab, or unbind and use an automation workspace." + `Session "${existing.session}" is bound to a user tab and does not own an OpenCLI tab lease.`, + "Use page commands on the bound tab, or unbind the session first." ); } try { @@ -1129,11 +1153,11 @@ async function getAutomationWindow(workspace, initialUrl) { await chrome.windows.get(existing.windowId); return existing.windowId; } catch { - await removeWorkspaceSession(workspace); + await removeLeaseSession(leaseKey); } } - const role = getOwnedWindowRole(workspace); - return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(workspace))).windowId; + const role = getOwnedWindowRole(leaseKey); + return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(leaseKey))).windowId; } chrome.windows.onRemoved.addListener(async (windowId) => { for (const container of Object.values(ownedContainers)) { @@ -1142,27 +1166,27 @@ chrome.windows.onRemoved.addListener(async (windowId) => { container.groupId = null; } } - for (const [workspace, session] of automationSessions.entries()) { + for (const [leaseKey, session] of automationSessions.entries()) { if (session.windowId === windowId) { - console.log(`[opencli] Automation container closed (${workspace})`); + console.log(`[opencli] ${session.surface} container closed (session=${session.session})`); if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - workspaceTimeoutOverrides.delete(workspace); - workspaceWindowModeOverrides.delete(workspace); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); } } await persistRuntimeState(); }); chrome.tabs.onRemoved.addListener(async (tabId) => { evictTab(tabId); - for (const [workspace, session] of automationSessions.entries()) { + for (const [leaseKey, session] of automationSessions.entries()) { if (session.preferredTabId === tabId) { if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - workspaceTimeoutOverrides.delete(workspace); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); - console.log(`[opencli] Workspace ${workspace} lease detached from tab ${tabId} (tab closed)`); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); } } await persistRuntimeState(); @@ -1194,8 +1218,8 @@ chrome.runtime.onStartup.addListener(() => { initialize(); chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === "keepalive") void connect(); - const workspace = workspaceFromAlarmName(alarm.name); - if (workspace) await releaseWorkspaceLease(workspace, "idle alarm"); + const leaseKey = leaseKeyFromAlarmName(alarm.name); + if (leaseKey) await releaseLease(leaseKey, "idle alarm"); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "getStatus") { @@ -1231,46 +1255,48 @@ async function fetchDaemonVersion() { } } async function handleCommand(cmd) { - const workspace = getWorkspaceKey(cmd.workspace); - if (!workspace.startsWith("bound:") && (cmd.windowMode === "foreground" || cmd.windowMode === "background")) { - workspaceWindowModeOverrides.set(workspace, cmd.windowMode); + const session = getSessionName(cmd.session); + const surface = getCommandSurface(cmd); + const leaseKey = getLeaseKey(session, surface); + if (cmd.windowMode === "foreground" || cmd.windowMode === "background") { + sessionWindowModeOverrides.set(leaseKey, cmd.windowMode); } if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { - workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1e3); + sessionTimeoutOverrides.set(leaseKey, cmd.idleTimeout * 1e3); } - resetWindowIdleTimer(workspace); + resetWindowIdleTimer(leaseKey); try { switch (cmd.action) { case "exec": - return await handleExec(cmd, workspace); + return await handleExec(cmd, leaseKey); case "navigate": - return await handleNavigate(cmd, workspace); + return await handleNavigate(cmd, leaseKey); case "tabs": - return await handleTabs(cmd, workspace); + return await handleTabs(cmd, leaseKey); case "cookies": return await handleCookies(cmd); case "screenshot": - return await handleScreenshot(cmd, workspace); + return await handleScreenshot(cmd, leaseKey); case "close-window": - return await handleCloseWindow(cmd, workspace); + return await handleCloseWindow(cmd, leaseKey); case "cdp": - return await handleCdp(cmd, workspace); + return await handleCdp(cmd, leaseKey); case "sessions": return await handleSessions(cmd); case "set-file-input": - return await handleSetFileInput(cmd, workspace); + return await handleSetFileInput(cmd, leaseKey); case "insert-text": - return await handleInsertText(cmd, workspace); + return await handleInsertText(cmd, leaseKey); case "bind": - return await handleBind(cmd, workspace); + return await handleBind(cmd, leaseKey); case "network-capture-start": - return await handleNetworkCaptureStart(cmd, workspace); + return await handleNetworkCaptureStart(cmd, leaseKey); case "network-capture-read": - return await handleNetworkCaptureRead(cmd, workspace); + return await handleNetworkCaptureRead(cmd, leaseKey); case "wait-download": return await handleWaitDownload(cmd); case "frames": - return await handleFrames(cmd, workspace); + return await handleFrames(cmd, leaseKey); default: return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; } @@ -1308,28 +1334,6 @@ function normalizeUrlForComparison(url) { function isTargetUrl(currentUrl, targetUrl) { return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } -function matchesDomain(url, domain) { - if (!url) return false; - try { - const parsed = new URL(url); - return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); - } catch { - return false; - } -} -function matchesBindCriteria(tab, cmd) { - if (!tab.id || !isDebuggableUrl(tab.url)) return false; - if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; - if (cmd.matchPathPrefix) { - try { - const parsed = new URL(tab.url); - if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false; - } catch { - return false; - } - } - return true; -} function getUrlOrigin(url) { if (!url) return null; try { @@ -1362,12 +1366,12 @@ function enumerateCrossOriginFrames(tree) { collect(tree.frameTree, getUrlOrigin(rootUrl)); return frames; } -function setWorkspaceSession(workspace, session) { - const existing = automationSessions.get(workspace); +function setLeaseSession(leaseKey, session) { + const existing = automationSessions.get(leaseKey); if (existing?.idleTimer) clearTimeout(existing.idleTimer); - const timeout = getIdleTimeout(workspace); - automationSessions.set(workspace, { - ...makeSession(workspace, session), + const timeout = getIdleTimeout(leaseKey); + automationSessions.set(leaseKey, { + ...makeSession(leaseKey, session), idleTimer: null, idleDeadlineAt: timeout <= 0 ? 0 : Date.now() + timeout }); @@ -1377,8 +1381,8 @@ async function resolveCommandTabId(cmd) { if (cmd.page) return resolveTabId$1(cmd.page); return void 0; } -async function resolveTab(tabId, workspace, initialUrl) { - const existingSession = automationSessions.get(workspace); +async function resolveTab(tabId, leaseKey, initialUrl) { + const existingSession = automationSessions.get(leaseKey); if (tabId !== void 0) { try { const tab = await chrome.tabs.get(tabId); @@ -1388,7 +1392,7 @@ async function resolveTab(tabId, workspace, initialUrl) { if (session && !session.owned) { throw new CommandFailure( matchesSession ? "bound_tab_not_debuggable" : "bound_tab_mismatch", - matchesSession ? `Bound tab for workspace "${workspace}" is not debuggable (${tab.url ?? "unknown URL"}).` : `Target tab is not the tab bound to workspace "${workspace}".`, + matchesSession ? `Bound tab for session "${session.session}" is not debuggable (${tab.url ?? "unknown URL"}).` : `Target tab is not the tab bound to session "${session.session}".`, 'Run "opencli browser bind" again on a debuggable http(s) tab.' ); } @@ -1409,10 +1413,10 @@ async function resolveTab(tabId, workspace, initialUrl) { } catch (err) { if (err instanceof CommandFailure) throw err; if (existingSession && !existingSession.owned) { - automationSessions.delete(workspace); + automationSessions.delete(leaseKey); throw new CommandFailure( "bound_tab_gone", - `Bound tab for workspace "${workspace}" no longer exists.`, + `Bound tab for session "${existingSession.session}" no longer exists.`, 'Run "opencli browser bind" again, then retry the command.' ); } @@ -1428,30 +1432,27 @@ async function resolveTab(tabId, workspace, initialUrl) { if (!session.owned) { throw new CommandFailure( "bound_tab_not_debuggable", - `Bound tab for workspace "${workspace}" is not debuggable (${preferredTab.url ?? "unknown URL"}).`, + `Bound tab for session "${session.session}" is not debuggable (${preferredTab.url ?? "unknown URL"}).`, 'Switch the tab to an http(s) page or run "opencli browser bind" on another tab.' ); } } catch (err) { if (err instanceof CommandFailure) throw err; - await removeWorkspaceSession(workspace); + await removeLeaseSession(leaseKey); if (!session.owned) { throw new CommandFailure( "bound_tab_gone", - `Bound tab for workspace "${workspace}" no longer exists.`, + `Bound tab for session "${session.session}" no longer exists.`, 'Run "opencli browser bind" again, then retry the command.' ); } - return createOwnedTabLease(workspace, initialUrl); + return createOwnedTabLease(leaseKey, initialUrl); } } - if (!existingSession && workspace.startsWith("bound:")) { - await getAutomationWindow(workspace, initialUrl); - } if (!existingSession || existingSession.owned && existingSession.preferredTabId === null) { - return createOwnedTabLease(workspace, initialUrl); + return createOwnedTabLease(leaseKey, initialUrl); } - const windowId = await getAutomationWindow(workspace, initialUrl); + const windowId = await getAutomationWindow(leaseKey, initialUrl); const tabs = await chrome.tabs.query({ windowId }); const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab }; @@ -1472,40 +1473,42 @@ async function resolveTab(tabId, workspace, initialUrl) { } async function pageScopedResult(id, tabId, data) { const page = await resolveTargetId(tabId); - return { id, ok: true, data, page }; + const lease = [...automationSessions.values()].find((session) => session.preferredTabId === tabId); + const scopedData = data && typeof data === "object" && !Array.isArray(data) ? { session: lease?.session, ...data } : { session: lease?.session, data }; + return { id, ok: true, data: scopedData, page }; } -async function resolveTabId(tabId, workspace, initialUrl) { - const resolved = await resolveTab(tabId, workspace, initialUrl); +async function resolveTabId(tabId, leaseKey, initialUrl) { + const resolved = await resolveTab(tabId, leaseKey, initialUrl); return resolved.tabId; } -async function listAutomationTabs(workspace) { - const session = automationSessions.get(workspace); +async function listAutomationTabs(leaseKey) { + const session = automationSessions.get(leaseKey); if (!session) return []; if (session.preferredTabId !== null) { try { return [await chrome.tabs.get(session.preferredTabId)]; } catch { - automationSessions.delete(workspace); + automationSessions.delete(leaseKey); return []; } } try { return await chrome.tabs.query({ windowId: session.windowId }); } catch { - automationSessions.delete(workspace); + automationSessions.delete(leaseKey); return []; } } -async function listAutomationWebTabs(workspace) { - const tabs = await listAutomationTabs(workspace); +async function listAutomationWebTabs(leaseKey) { + const tabs = await listAutomationTabs(leaseKey); return tabs.filter((tab) => isDebuggableUrl(tab.url)); } -async function handleExec(cmd, workspace) { +async function handleExec(cmd, leaseKey) { if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { - const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); + const aggressive = getSurfaceFromKey(leaseKey) === "browser"; if (cmd.frameIndex != null) { const tree = await getFrameTree(tabId); const frames = enumerateCrossOriginFrames(tree); @@ -1521,9 +1524,9 @@ async function handleExec(cmd, workspace) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } -async function handleFrames(cmd, workspace) { +async function handleFrames(cmd, leaseKey) { const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { const tree = await getFrameTree(tabId); return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; @@ -1531,23 +1534,13 @@ async function handleFrames(cmd, workspace) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } -async function handleNavigate(cmd, workspace) { +async function handleNavigate(cmd, leaseKey) { if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; if (!isSafeNavigationUrl(cmd.url)) { return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; } - const session = automationSessions.get(workspace); - if (session && !session.owned && cmd.allowBoundNavigation !== true) { - return { - id: cmd.id, - ok: false, - errorCode: "bound_navigation_blocked", - error: `Workspace "${workspace}" is bound to a user tab; navigation is blocked by default.`, - errorHint: "Pass --allow-navigate-bound only if you intentionally want to navigate the bound tab." - }; - } const cmdTabId = await resolveCommandTabId(cmd); - const resolved = await resolveTab(cmdTabId, workspace, cmd.url); + const resolved = await resolveTab(cmdTabId, leaseKey, cmd.url); const tabId = resolved.tabId; const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); const beforeNormalized = normalizeUrlForComparison(beforeTab.url); @@ -1598,16 +1591,7 @@ async function handleNavigate(cmd, workspace) { }, 15e3); }); let tab = await chrome.tabs.get(tabId); - const postNavigationSession = automationSessions.get(workspace); - if (postNavigationSession?.owned === false && tab.windowId !== postNavigationSession.windowId) { - return { - id: cmd.id, - ok: false, - errorCode: "bound_tab_moved", - error: `Bound tab for workspace "${workspace}" moved to another window during navigation.`, - errorHint: 'Run "opencli browser bind" again on the intended tab.' - }; - } + const postNavigationSession = automationSessions.get(leaseKey); if (postNavigationSession && tab.windowId !== postNavigationSession.windowId) { console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${postNavigationSession.windowId}`); try { @@ -1619,20 +1603,20 @@ async function handleNavigate(cmd, workspace) { } return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut }); } -async function handleTabs(cmd, workspace) { - const session = automationSessions.get(workspace); +async function handleTabs(cmd, leaseKey) { + const session = automationSessions.get(leaseKey); if (session && !session.owned && cmd.op !== "list") { return { id: cmd.id, ok: false, errorCode: "bound_tab_mutation_blocked", - error: `Workspace "${workspace}" is bound to a user tab; tab mutation is blocked by default.`, - errorHint: "Use an automation workspace for tab new/select/close, or unbind first." + error: `Session "${session.session}" is bound to a user tab; tab new/select/close requires an owned OpenCLI session.`, + errorHint: "Unbind the session first, or use a different session for owned OpenCLI tabs." }; } switch (cmd.op) { case "list": { - const tabs = await listAutomationWebTabs(workspace); + const tabs = await listAutomationWebTabs(leaseKey); const data = await Promise.all(tabs.map(async (t, i) => { let page; try { @@ -1647,31 +1631,34 @@ async function handleTabs(cmd, workspace) { if (cmd.url && !isSafeNavigationUrl(cmd.url)) { return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; } - if (!automationSessions.has(workspace)) { - const created = await createOwnedTabLease(workspace, cmd.url); + if (!automationSessions.has(leaseKey)) { + const created = await createOwnedTabLease(leaseKey, cmd.url); return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url }); } - const windowId = await getAutomationWindow(workspace); + const windowId = await getAutomationWindow(leaseKey); const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); if (!tab.id) return { id: cmd.id, ok: false, error: "Failed to create tab" }; - await ensureOwnedContainerTabGroup(getOwnedWindowRole(workspace), windowId, [tab.id]); - setWorkspaceSession(workspace, { + await ensureOwnedContainerTabGroup(getOwnedWindowRole(leaseKey), windowId, [tab.id]); + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "owned", windowId: tab.windowId, owned: true, preferredTabId: tab.id }); - resetWindowIdleTimer(workspace); + resetWindowIdleTimer(leaseKey); return pageScopedResult(cmd.id, tab.id, { url: tab.url }); } case "close": { if (cmd.index !== void 0) { - const tabs = await listAutomationWebTabs(workspace); + const tabs = await listAutomationWebTabs(leaseKey); const target = tabs[cmd.index]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; const closedPage2 = await resolveTargetId(target.id).catch(() => void 0); - const currentSession2 = automationSessions.get(workspace); + const currentSession2 = automationSessions.get(leaseKey); if (currentSession2?.preferredTabId === target.id) { - await releaseWorkspaceLease(workspace, "tab close"); + await releaseLease(leaseKey, "tab close"); } else { await safeDetach(target.id); await chrome.tabs.remove(target.id); @@ -1679,11 +1666,11 @@ async function handleTabs(cmd, workspace) { return { id: cmd.id, ok: true, data: { closed: closedPage2 } }; } const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); const closedPage = await resolveTargetId(tabId).catch(() => void 0); - const currentSession = automationSessions.get(workspace); + const currentSession = automationSessions.get(leaseKey); if (currentSession?.preferredTabId === tabId) { - await releaseWorkspaceLease(workspace, "tab close"); + await releaseLease(leaseKey, "tab close"); } else { await safeDetach(tabId); await chrome.tabs.remove(tabId); @@ -1695,7 +1682,7 @@ async function handleTabs(cmd, workspace) { return { id: cmd.id, ok: false, error: "Missing index or page" }; const cmdTabId = await resolveCommandTabId(cmd); if (cmdTabId !== void 0) { - const session2 = automationSessions.get(workspace); + const session2 = automationSessions.get(leaseKey); let tab; try { tab = await chrome.tabs.get(cmdTabId); @@ -1708,7 +1695,7 @@ async function handleTabs(cmd, workspace) { await chrome.tabs.update(cmdTabId, { active: true }); return pageScopedResult(cmd.id, cmdTabId, { selected: true }); } - const tabs = await listAutomationWebTabs(workspace); + const tabs = await listAutomationWebTabs(leaseKey); const target = tabs[cmd.index]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; await chrome.tabs.update(target.id, { active: true }); @@ -1737,9 +1724,9 @@ async function handleCookies(cmd) { })); return { id: cmd.id, ok: true, data }; } -async function handleScreenshot(cmd, workspace) { +async function handleScreenshot(cmd, leaseKey) { const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { const data = await screenshot(tabId, { format: cmd.format, @@ -1781,15 +1768,15 @@ const CDP_ALLOWLIST = /* @__PURE__ */ new Set([ "Emulation.setDeviceMetricsOverride", "Emulation.clearDeviceMetricsOverride" ]); -async function handleCdp(cmd, workspace) { +async function handleCdp(cmd, leaseKey) { if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: "Missing cdpMethod" }; if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; } const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { - const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); + const aggressive = getSurfaceFromKey(leaseKey) === "browser"; await ensureAttached(tabId, aggressive); const params = cmd.cdpParams ?? {}; const routeFrameId = typeof params.frameId === "string" && params.sessionId === "target" ? params.frameId : void 0; @@ -1809,16 +1796,17 @@ function stripOpenCliFrameRoutingParams(params, stripFrameId) { if (!stripFrameId && frameId !== void 0) return { ...rest, frameId }; return rest; } -async function handleCloseWindow(cmd, workspace) { - await releaseWorkspaceLease(workspace, "explicit close"); - return { id: cmd.id, ok: true, data: { closed: true, workspace } }; +async function handleCloseWindow(cmd, leaseKey) { + const sessionName = automationSessions.get(leaseKey)?.session ?? getSessionFromKey(leaseKey); + await releaseLease(leaseKey, "explicit close"); + return { id: cmd.id, ok: true, data: { closed: true, session: sessionName } }; } -async function handleSetFileInput(cmd, workspace) { +async function handleSetFileInput(cmd, leaseKey) { if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { return { id: cmd.id, ok: false, error: "Missing or empty files array" }; } const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { await setFileInputFiles(tabId, cmd.files, cmd.selector); return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); @@ -1826,12 +1814,12 @@ async function handleSetFileInput(cmd, workspace) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } -async function handleInsertText(cmd, workspace) { +async function handleInsertText(cmd, leaseKey) { if (typeof cmd.text !== "string") { return { id: cmd.id, ok: false, error: "Missing text payload" }; } const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { await insertText(tabId, cmd.text); return pageScopedResult(cmd.id, tabId, { inserted: true }); @@ -1839,9 +1827,9 @@ async function handleInsertText(cmd, workspace) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } -async function handleNetworkCaptureStart(cmd, workspace) { +async function handleNetworkCaptureStart(cmd, leaseKey) { const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { await startNetworkCapture(tabId, cmd.pattern); return pageScopedResult(cmd.id, tabId, { started: true }); @@ -1849,9 +1837,9 @@ async function handleNetworkCaptureStart(cmd, workspace) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } -async function handleNetworkCaptureRead(cmd, workspace) { +async function handleNetworkCaptureRead(cmd, leaseKey) { const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { const data = await readNetworkCapture(tabId); return pageScopedResult(cmd.id, tabId, data); @@ -1867,49 +1855,49 @@ async function handleWaitDownload(cmd) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } -async function releaseWorkspaceLease(workspace, reason = "released") { - const session = automationSessions.get(workspace); +async function releaseLease(leaseKey, reason = "released") { + const session = automationSessions.get(leaseKey); if (!session) { - workspaceTimeoutOverrides.delete(workspace); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); + sessionTimeoutOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); return; } if (session.idleTimer) clearTimeout(session.idleTimer); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); if (session.owned) { const tabId = session.preferredTabId; if (tabId !== null) { const hasOtherOwnedLease = [...automationSessions.entries()].some( - ([otherWorkspace, otherSession]) => otherWorkspace !== workspace && otherSession.owned && otherSession.windowId === session.windowId && otherSession.preferredTabId !== null + ([otherLease, otherSession]) => otherLease !== leaseKey && otherSession.owned && otherSession.windowId === session.windowId && otherSession.preferredTabId !== null ); await safeDetach(tabId); evictTab(tabId); if (hasOtherOwnedLease) { await chrome.tabs.remove(tabId).catch(() => { }); - console.log(`[opencli] Released owned tab lease ${tabId} (${workspace}, ${reason})`); + console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); } else { try { const tab = await chrome.tabs.update(tabId, { url: BLANK_PAGE, active: true }); - await ensureOwnedContainerTabGroup(getOwnedWindowRole(workspace), session.windowId, [tab.id ?? tabId]); - console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (${workspace}, ${reason})`); + await ensureOwnedContainerTabGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]); + console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`); } catch { await chrome.tabs.remove(tabId).catch(() => { }); - console.log(`[opencli] Released owned tab lease ${tabId} (${workspace}, ${reason})`); + console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); } } } else { - console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (${workspace}, ${reason})`); + console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (session=${session.session}, surface=${session.surface}, ${reason})`); } } else if (session.preferredTabId !== null) { await safeDetach(session.preferredTabId); - console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (${workspace}, ${reason})`); + console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (session=${session.session}, surface=${session.surface}, ${reason})`); } - automationSessions.delete(workspace); - workspaceTimeoutOverrides.delete(workspace); - workspaceWindowModeOverrides.delete(workspace); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); await persistRuntimeState(); } async function reconcileTargetLeaseRegistry() { @@ -1928,34 +1916,37 @@ async function reconcileTargetLeaseRegistry() { } } automationSessions.clear(); - for (const [workspace, stored] of Object.entries(registry.leases)) { + for (const [leaseKey, stored] of Object.entries(registry.leases)) { const tabId = stored.preferredTabId; if (tabId === null) continue; try { const tab = await chrome.tabs.get(tabId); if (!isDebuggableUrl(tab.url)) continue; - const session = makeSession(workspace, { + const session = makeSession(leaseKey, { + session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey), + surface: stored.surface === "adapter" ? "adapter" : getSurfaceFromKey(leaseKey), + kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned", windowId: tab.windowId, owned: stored.owned, preferredTabId: tabId }); - const timeout = getIdleTimeout(workspace); - automationSessions.set(workspace, { + const timeout = getIdleTimeout(leaseKey); + automationSessions.set(leaseKey, { ...session, idleTimer: null, idleDeadlineAt: stored.idleDeadlineAt }); if (session.owned) { - const role = getOwnedWindowRole(workspace); + const role = getOwnedWindowRole(leaseKey); if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId; await ensureOwnedContainerTabGroup(role, tab.windowId, [tabId]); } const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout; if (timeout > 0) { if (remaining <= 0) { - await releaseWorkspaceLease(workspace, "reconciled idle expiry"); + await releaseLease(leaseKey, "reconciled idle expiry"); } else { - resetWindowIdleTimer(workspace); + resetWindowIdleTimer(leaseKey); } } } catch { @@ -1965,10 +1956,12 @@ async function reconcileTargetLeaseRegistry() { } async function handleSessions(cmd) { const now = Date.now(); - const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ - workspace, + const data = await Promise.all([...automationSessions.entries()].map(async ([leaseKey, session]) => ({ + session: session.session, windowId: session.windowId, owned: session.owned, + kind: session.kind, + surface: session.surface, preferredTabId: session.preferredTabId, contextId: session.contextId, ownership: session.ownership, @@ -1979,52 +1972,41 @@ async function handleSessions(cmd) { }))); return { id: cmd.id, ok: true, data }; } -async function handleBind(cmd, workspace) { - if (!workspace.startsWith("bound:")) { - return { - id: cmd.id, - ok: false, - errorCode: "invalid_bind_workspace", - error: `bind workspace must start with "bound:", got "${workspace}".`, - errorHint: 'Use the default "bound:default" or pass --workspace bound:.' - }; - } - const existing = automationSessions.get(workspace); +async function handleBind(cmd, leaseKey) { + const existing = automationSessions.get(leaseKey); if (existing?.owned) { - return { - id: cmd.id, - ok: false, - errorCode: "invalid_bind_workspace", - error: `Workspace "${workspace}" already owns an automation tab lease and cannot be rebound to a user tab.`, - errorHint: "Use a fresh bound: workspace, or close/unbind the existing session first." - }; + await releaseLease(leaseKey, "rebind"); } const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); - const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)); + const boundTab = activeTabs.find((tab) => isDebuggableUrl(tab.url)) ?? fallbackTabs.find((tab) => isDebuggableUrl(tab.url)); if (!boundTab?.id) { return { id: cmd.id, ok: false, errorCode: "bound_tab_not_found", - error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab in the current window matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No debuggable tab found in the current window", - errorHint: "Focus the target Chrome tab/window or relax --domain / --path-prefix, then retry bind." + error: "No debuggable tab found in the current window", + errorHint: "Focus the target Chrome tab/window, then retry bind." }; } - if (existing && !existing.owned && existing.preferredTabId !== null && existing.preferredTabId !== boundTab.id) { - await detach(existing.preferredTabId).catch(() => { + const current = automationSessions.get(leaseKey); + if (current && !current.owned && current.preferredTabId !== null && current.preferredTabId !== boundTab.id) { + await detach(current.preferredTabId).catch(() => { }); } - setWorkspaceSession(workspace, { + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "bound", windowId: boundTab.windowId, owned: false, preferredTabId: boundTab.id }); - resetWindowIdleTimer(workspace); - console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + resetWindowIdleTimer(leaseKey); + console.log(`[opencli] Session ${getSessionFromKey(leaseKey)} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); return pageScopedResult(cmd.id, boundTab.id, { url: boundTab.url, title: boundTab.title, - workspace + session: getSessionFromKey(leaseKey) }); } diff --git a/extension/manifest.json b/extension/manifest.json index cc453f964..d130c9171 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "OpenCLI", - "version": "1.0.10", + "version": "1.0.11", "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.", "permissions": [ "debugger", diff --git a/extension/package-lock.json b/extension/package-lock.json index c9294d376..477d0fc90 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencli-extension", - "version": "1.0.10", + "version": "1.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencli-extension", - "version": "1.0.10", + "version": "1.0.11", "devDependencies": { "@types/chrome": "^0.0.287", "typescript": "^5.7.0", diff --git a/extension/package.json b/extension/package.json index 1f03a918b..ebd8a6a02 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "opencli-extension", - "version": "1.0.10", + "version": "1.0.11", "private": true, "opencli": { "compatRange": ">=1.7.0" diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 176ea4312..33816a09d 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -23,6 +23,11 @@ type MockTabGroup = { collapsed?: boolean; }; +const leaseKey = (surface: 'browser' | 'adapter', session: string): string => + `${surface}\u0000${encodeURIComponent(session)}`; +const browserKey = (session: string): string => leaseKey('browser', session); +const adapterKey = (session: string): string => leaseKey('adapter', session); + class MockWebSocket { static OPEN = 1; static CONNECTING = 0; @@ -202,9 +207,9 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); - const result = await mod.__test__.handleTabs({ id: '1', action: 'tabs', op: 'list', workspace: 'site:twitter' }, 'site:twitter'); + const result = await mod.__test__.handleTabs({ id: '1', action: 'tabs', op: 'list', session: adapterKey('twitter') }, adapterKey('twitter')); expect(result.ok).toBe(true); expect(result.data).toEqual([ @@ -253,9 +258,9 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); - const result = await mod.__test__.handleCommand({ id: 'frames', action: 'frames', workspace: 'site:twitter' }); + const result = await mod.__test__.handleCommand({ id: 'frames', action: 'frames', session: 'twitter', surface: 'adapter' }); expect(result.ok).toBe(true); expect(result.data).toEqual([ @@ -270,12 +275,13 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); const result = await mod.__test__.handleCommand({ id: 'ax-enable', action: 'cdp', - workspace: 'site:twitter', + session: 'twitter', + surface: 'adapter', cdpMethod: 'Accessibility.enable', cdpParams: {}, }); @@ -309,17 +315,21 @@ describe('background tab isolation', () => { })); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); const result = await mod.__test__.handleCommand({ id: 'frame-ax', action: 'cdp', - workspace: 'site:twitter', + session: 'twitter', + surface: 'adapter', cdpMethod: 'Accessibility.getFullAXTree', cdpParams: { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://frame.test/' }, }); - expect(result).toEqual(expect.objectContaining({ ok: true, data: { nodes: [] } })); + expect(result).toEqual(expect.objectContaining({ + ok: true, + data: expect.objectContaining({ nodes: [] }), + })); expect(sendCommandInFrameTarget).toHaveBeenCalledWith( 1, 'cross-frame', @@ -354,7 +364,8 @@ describe('background tab isolation', () => { action: 'wait-download', pattern: 'receipt', timeoutMs: 1234, - workspace: 'site:mercury', + session: 'mercury', + surface: 'adapter', }); expect(result).toEqual({ @@ -407,15 +418,16 @@ describe('background tab isolation', () => { })); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); - const listResult = await mod.__test__.handleCommand({ id: 'frames', action: 'frames', workspace: 'site:twitter' }); + const listResult = await mod.__test__.handleCommand({ id: 'frames', action: 'frames', session: 'twitter', surface: 'adapter' }); const execResult = await mod.__test__.handleCommand({ id: 'exec-in-frame', action: 'exec', code: 'document.title', frameIndex: 0, - workspace: 'site:twitter', + session: 'twitter', + surface: 'adapter', }); expect(listResult.ok).toBe(true); @@ -432,9 +444,9 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); - const result = await mod.__test__.handleTabs({ id: '2', action: 'tabs', op: 'new', url: 'https://new.example', workspace: 'site:twitter' }, 'site:twitter'); + const result = await mod.__test__.handleTabs({ id: '2', action: 'tabs', op: 'new', url: 'https://new.example', session: adapterKey('twitter') }, adapterKey('twitter')); expect(result.ok).toBe(true); expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true }); @@ -446,8 +458,8 @@ describe('background tab isolation', () => { const mod = await import('./background'); const result = await mod.__test__.handleTabs( - { id: 'first-new', action: 'tabs', op: 'new', url: 'https://first.example', workspace: 'browser:first-new' }, - 'browser:first-new', + { id: 'first-new', action: 'tabs', op: 'new', url: 'https://first.example', session: browserKey('default') }, + browserKey('default'), ); expect(result.ok).toBe(true); @@ -461,11 +473,11 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); const result = await mod.__test__.handleTabs( - { id: 'close-by-page', action: 'tabs', op: 'close', workspace: 'site:twitter', page: 'target-1' }, - 'site:twitter', + { id: 'close-by-page', action: 'tabs', op: 'close', session: adapterKey('twitter'), page: 'target-1' }, + adapterKey('twitter'), ); expect(result).toEqual({ @@ -484,11 +496,11 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:bilibili', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); const result = await mod.__test__.handleNavigate( - { id: 'same-url', action: 'navigate', url: 'https://www.bilibili.com', workspace: 'site:bilibili' }, - 'site:bilibili', + { id: 'same-url', action: 'navigate', url: 'https://www.bilibili.com', session: adapterKey('twitter') }, + adapterKey('twitter'), ); expect(result).toEqual({ @@ -499,6 +511,7 @@ describe('background tab isolation', () => { title: 'bilibili', url: 'https://www.bilibili.com/', timedOut: false, + session: 'twitter', }, }); expect(update).not.toHaveBeenCalled(); @@ -533,11 +546,11 @@ describe('background tab isolation', () => { })); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:eos', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); const result = await mod.__test__.handleNavigate( - { id: 'capture-nav', action: 'navigate', url: 'https://eos.douyin.com/livesite/live/current', workspace: 'site:eos' }, - 'site:eos', + { id: 'capture-nav', action: 'navigate', url: 'https://eos.douyin.com/livesite/live/current', session: adapterKey('twitter') }, + adapterKey('twitter'), ); expect(result.ok).toBe(true); @@ -555,19 +568,19 @@ describe('background tab isolation', () => { expect(mod.__test__.isTargetUrl('https://example.com/app/', 'https://example.com/app')).toBe(false); }); - it('reports sessions per workspace', async () => { + it('reports sessions per session', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); - mod.__test__.setAutomationWindowId('site:zhihu', 2); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + mod.__test__.setAutomationWindowId(adapterKey('zhihu'), 2); const result = await mod.__test__.handleSessions({ id: '3', action: 'sessions' }); expect(result.ok).toBe(true); expect(result.data).toEqual(expect.arrayContaining([ - expect.objectContaining({ workspace: 'site:twitter', windowId: 1 }), - expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }), + expect.objectContaining({ session: 'twitter', surface: 'adapter', windowId: 1 }), + expect.objectContaining({ session: 'zhihu', surface: 'adapter', windowId: 2 }), ])); }); @@ -590,7 +603,7 @@ describe('background tab isolation', () => { }); }); - it('can execute concurrently on two pages in the same workspace', async () => { + it('can execute concurrently on two pages in the same session', async () => { const { chrome, tabs } = createChromeMock(); tabs.push({ id: 4, @@ -616,11 +629,11 @@ describe('background tab isolation', () => { })); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:parallel', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); const [first, second] = await Promise.all([ - mod.__test__.handleExec({ id: 'p1', action: 'exec', workspace: 'site:parallel', page: 'target-1', code: 'window.__task = 1' }, 'site:parallel'), - mod.__test__.handleExec({ id: 'p2', action: 'exec', workspace: 'site:parallel', page: 'target-4', code: 'window.__task = 2' }, 'site:parallel'), + mod.__test__.handleExec({ id: 'p1', action: 'exec', session: adapterKey('twitter'), page: 'target-1', code: 'window.__task = 1' }, adapterKey('twitter')), + mod.__test__.handleExec({ id: 'p2', action: 'exec', session: adapterKey('twitter'), page: 'target-4', code: 'window.__task = 2' }, adapterKey('twitter')), ]); expect(first).toEqual(expect.objectContaining({ @@ -636,7 +649,7 @@ describe('background tab isolation', () => { expect(maxInFlight).toBe(2); }); - it('can execute concurrently across two workspaces in the shared container window', async () => { + it('can execute concurrently across two sessions in the shared container window', async () => { const { chrome, create } = createChromeMock(); vi.stubGlobal('chrome', chrome); @@ -654,48 +667,48 @@ describe('background tab isolation', () => { })); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); - mod.__test__.setAutomationWindowId('site:zhihu', 2); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + mod.__test__.setAutomationWindowId(adapterKey('zhihu'), 2); const [first, second] = await Promise.all([ - mod.__test__.handleExec({ id: 'w1', action: 'exec', workspace: 'site:twitter', code: 'window.__window = 1' }, 'site:twitter'), - mod.__test__.handleExec({ id: 'w2', action: 'exec', workspace: 'site:zhihu', code: 'window.__window = 2' }, 'site:zhihu'), + mod.__test__.handleExec({ id: 'w1', action: 'exec', session: adapterKey('twitter'), code: 'window.__window = 1' }, adapterKey('twitter')), + mod.__test__.handleExec({ id: 'w2', action: 'exec', session: adapterKey('zhihu'), code: 'window.__window = 2' }, adapterKey('zhihu')), ]); expect(first).toEqual(expect.objectContaining({ ok: true, page: 'target-1', - data: { tabId: 1, code: 'window.__window = 1' }, + data: expect.objectContaining({ tabId: 1, code: 'window.__window = 1' }), })); expect(second).toEqual(expect.objectContaining({ ok: true, page: 'target-10', - data: { tabId: 10, code: 'window.__window = 2' }, + data: expect.objectContaining({ tabId: 10, code: 'window.__window = 2' }), })); expect(maxInFlight).toBe(2); expect(chrome.windows.create).toHaveBeenCalledTimes(1); expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'about:blank', active: true }); }); - it('releases owned workspaces without closing the shared container', async () => { + it('releases owned sessions without closing the shared container', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - await mod.__test__.resolveTabId(undefined, 'site:first'); - await mod.__test__.resolveTabId(undefined, 'site:second'); - expect(mod.__test__.getSession('site:second')).toEqual(expect.objectContaining({ preferredTabId: 10 })); + await mod.__test__.resolveTabId(undefined, adapterKey('first')); + await mod.__test__.resolveTabId(undefined, adapterKey('second')); + expect(mod.__test__.getSession(adapterKey('second'))).toEqual(expect.objectContaining({ preferredTabId: 10 })); - const closeSecond = await mod.__test__.handleCommand({ id: 'close-second', action: 'close-window', workspace: 'site:second' }); + const closeSecond = await mod.__test__.handleCommand({ id: 'close-second', action: 'close-window', session: 'second', surface: 'adapter' }); expect(closeSecond).toEqual(expect.objectContaining({ ok: true })); expect(chrome.tabs.remove).toHaveBeenCalledWith(10); expect(chrome.tabs.update).not.toHaveBeenCalledWith(10, { url: 'about:blank', active: true }); expect(chrome.windows.remove).not.toHaveBeenCalled(); - expect(mod.__test__.getSession('site:first')).not.toBeNull(); - expect(mod.__test__.getSession('site:second')).toBeNull(); + expect(mod.__test__.getSession(adapterKey('first'))).not.toBeNull(); + expect(mod.__test__.getSession(adapterKey('second'))).toBeNull(); - await mod.__test__.handleCommand({ id: 'close-first', action: 'close-window', workspace: 'site:first' }); - expect(chrome.tabs.update).toHaveBeenCalledWith(1, { url: 'about:blank', active: true }); + await mod.__test__.handleCommand({ id: 'close-first', action: 'close-window', session: 'first', surface: 'adapter' }); + expect(chrome.tabs.update).toHaveBeenCalledWith(1, { url: 'about:blank' }); expect(chrome.windows.remove).not.toHaveBeenCalled(); }); @@ -704,11 +717,11 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - await mod.__test__.resolveTabId(undefined, 'site:closetab'); + await mod.__test__.resolveTabId(undefined, adapterKey('twitter')); const result = await mod.__test__.handleTabs( - { id: 'close-current-lease', action: 'tabs', op: 'close', workspace: 'site:closetab' }, - 'site:closetab', + { id: 'close-current-lease', action: 'tabs', op: 'close', session: adapterKey('twitter') }, + adapterKey('twitter'), ); expect(result).toEqual(expect.objectContaining({ @@ -718,7 +731,7 @@ describe('background tab isolation', () => { })); expect(chrome.tabs.update).toHaveBeenCalledWith(1, { url: 'about:blank', active: true }); expect(chrome.windows.remove).not.toHaveBeenCalled(); - expect(mod.__test__.getSession('site:closetab')).toBeNull(); + expect(mod.__test__.getSession(adapterKey('twitter'))).toBeNull(); }); it('reconciles an owned container with no stored leases without closing it', async () => { @@ -740,7 +753,7 @@ describe('background tab isolation', () => { expect(mod.__test__.getAutomationWindowId()).toBeNull(); chrome.windows.create.mockClear(); - const tabId = await mod.__test__.resolveTabId(undefined, 'site:after-restart', 'https://after.example'); + const tabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter'), 'https://after.example'); expect(tabId).toBe(1); expect(chrome.windows.create).not.toHaveBeenCalled(); @@ -757,7 +770,7 @@ describe('background tab isolation', () => { contextId: 'user-default', ownedContainers: { interactive: { windowId: null }, automation: { windowId: 1 } }, leases: { - 'site:restored': { + [adapterKey('twitter')]: { windowId: 1, owned: true, preferredTabId: 1, @@ -768,7 +781,7 @@ describe('background tab isolation', () => { idleDeadlineAt: deadline, updatedAt: Date.now(), }, - 'bound:restored': { + [browserKey('default')]: { windowId: 2, owned: false, preferredTabId: 2, @@ -786,14 +799,14 @@ describe('background tab isolation', () => { const mod = await import('./background'); await mod.__test__.reconcileTargetLeaseRegistry(); - expect(mod.__test__.getSession('site:restored')).toEqual(expect.objectContaining({ + expect(mod.__test__.getSession(adapterKey('twitter'))).toEqual(expect.objectContaining({ owned: true, ownership: 'owned', lifecycle: 'ephemeral', windowRole: 'automation', preferredTabId: 1, })); - expect(mod.__test__.getSession('bound:restored')).toEqual(expect.objectContaining({ + expect(mod.__test__.getSession(browserKey('default'))).toEqual(expect.objectContaining({ owned: false, ownership: 'borrowed', lifecycle: 'pinned', @@ -803,7 +816,7 @@ describe('background tab isolation', () => { idleDeadlineAt: 0, })); expect(chrome.alarms.create).toHaveBeenCalledWith( - 'opencli:lease-idle:site%3Arestored', + `opencli:lease-idle:${encodeURIComponent(adapterKey('twitter'))}`, expect.objectContaining({ when: expect.any(Number) }), ); expect(chrome.windows.remove).not.toHaveBeenCalled(); @@ -814,14 +827,14 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - await mod.__test__.resolveTabId(undefined, 'site:alarm'); + await mod.__test__.resolveTabId(undefined, adapterKey('alarm')); const onAlarmListener = chrome.alarms.onAlarm.addListener.mock.calls[0][0]; - await onAlarmListener({ name: 'opencli:lease-idle:site%3Aalarm' }); + await onAlarmListener({ name: `opencli:lease-idle:${encodeURIComponent(adapterKey('alarm'))}` }); - expect(chrome.tabs.update).toHaveBeenCalledWith(1, { url: 'about:blank', active: true }); + expect(chrome.tabs.update).toHaveBeenCalledWith(1, { url: 'about:blank' }); expect(chrome.windows.remove).not.toHaveBeenCalled(); - expect(mod.__test__.getSession('site:alarm')).toBeNull(); + expect(mod.__test__.getSession(adapterKey('alarm'))).toBeNull(); }); it('reuses the placeholder tab left by an idle release', async () => { @@ -829,16 +842,16 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - await mod.__test__.resolveTabId(undefined, 'site:first'); + await mod.__test__.resolveTabId(undefined, adapterKey('first')); const onAlarmListener = chrome.alarms.onAlarm.addListener.mock.calls[0][0]; - await onAlarmListener({ name: 'opencli:lease-idle:site%3Afirst' }); + await onAlarmListener({ name: `opencli:lease-idle:${encodeURIComponent(adapterKey('first'))}` }); expect(tabs[0].url).toBe('about:blank'); expect(chrome.windows.remove).not.toHaveBeenCalled(); chrome.windows.create.mockClear(); - const reused = await mod.__test__.resolveTabId(undefined, 'site:next', 'https://next.example'); + const reused = await mod.__test__.resolveTabId(undefined, adapterKey('next'), 'https://next.example'); expect(reused).toBe(1); expect(chrome.windows.create).not.toHaveBeenCalled(); @@ -854,12 +867,12 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setSession('site:stale-a', { windowId: 90, owned: true, preferredTabId: null }); - mod.__test__.setSession('site:stale-b', { windowId: 91, owned: true, preferredTabId: null }); + mod.__test__.setSession(adapterKey('stale-a'), { windowId: 90, owned: true, preferredTabId: null }); + mod.__test__.setSession(adapterKey('stale-b'), { windowId: 91, owned: true, preferredTabId: null }); const [first, second] = await Promise.all([ - mod.__test__.handleTabs({ id: 'new-a', action: 'tabs', op: 'new', workspace: 'site:stale-a', url: 'https://a.example' }, 'site:stale-a'), - mod.__test__.handleTabs({ id: 'new-b', action: 'tabs', op: 'new', workspace: 'site:stale-b', url: 'https://b.example' }, 'site:stale-b'), + mod.__test__.handleTabs({ id: 'new-a', action: 'tabs', op: 'new', session: adapterKey('stale-a'), url: 'https://a.example' }, adapterKey('stale-a')), + mod.__test__.handleTabs({ id: 'new-b', action: 'tabs', op: 'new', session: adapterKey('stale-b'), url: 'https://b.example' }, adapterKey('stale-b')), ]); expect(first).toEqual(expect.objectContaining({ ok: true })); @@ -872,7 +885,7 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - const tabId = await mod.__test__.resolveTabId(undefined, 'site:new-group'); + const tabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter')); expect(tabId).toBe(1); expect(tabs[0].groupId).toBe(100); @@ -888,7 +901,7 @@ describe('background tab isolation', () => { expect(chrome.tabs.group).toHaveBeenCalledWith({ tabIds: [1], createProperties: { windowId: 1 } }); }); - it('uses separate owned windows for browser and adapter workspaces', async () => { + it('uses separate owned windows for browser and adapter sessions', async () => { const { chrome, tabs, groups } = createChromeMock(); let nextWindowId = 20; let nextTabId = 200; @@ -909,8 +922,8 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - const browserTabId = await mod.__test__.resolveTabId(undefined, 'browser:default'); - const adapterTabId = await mod.__test__.resolveTabId(undefined, 'site:twitter'); + const browserTabId = await mod.__test__.resolveTabId(undefined, browserKey('default')); + const adapterTabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter')); expect(tabs.find((tab) => tab.id === browserTabId)?.windowId).toBe(20); expect(tabs.find((tab) => tab.id === adapterTabId)?.windowId).toBe(21); @@ -946,7 +959,7 @@ describe('background tab isolation', () => { id: 'new-foreground', action: 'tabs', op: 'new', - workspace: 'site:twitter', + session: adapterKey('twitter'), url: 'https://x.com', windowMode: 'foreground', }); @@ -960,8 +973,8 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - await mod.__test__.resolveTabId(undefined, 'site:first'); - const secondTabId = await mod.__test__.resolveTabId(undefined, 'site:second'); + await mod.__test__.resolveTabId(undefined, adapterKey('first')); + const secondTabId = await mod.__test__.resolveTabId(undefined, adapterKey('second')); expect(secondTabId).toBe(10); expect(groups).toHaveLength(1); @@ -981,7 +994,7 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - const tabId = await mod.__test__.resolveTabId(undefined, 'site:restored-group'); + const tabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter')); expect(tabId).toBe(1); expect(tabs[0].groupId).toBe(99); @@ -1006,7 +1019,7 @@ describe('background tab isolation', () => { contextId: 'user-default', ownedContainers: { interactive: { windowId: null }, automation: { windowId: 1, groupId: 99 } }, leases: { - 'site:restored-group': { + [adapterKey('twitter')]: { windowId: 1, owned: true, preferredTabId: 1, @@ -1053,7 +1066,7 @@ describe('background tab isolation', () => { contextId: 'user-default', ownedContainers: { interactive: { windowId: null }, automation: { windowId: 1, groupId: 404 } }, leases: { - 'site:restored-group': { + [adapterKey('twitter')]: { windowId: 1, owned: true, preferredTabId: 1, @@ -1077,14 +1090,14 @@ describe('background tab isolation', () => { expect(chrome.tabGroups.update).not.toHaveBeenCalled(); }); - it('does not group borrowed user tabs for bound workspaces', async () => { + it('does not group borrowed user tabs for bound sessions', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); const result = await mod.__test__.handleBind( - { id: 'bind', action: 'bind', workspace: 'bound:default' }, - 'bound:default', + { id: 'bind', action: 'bind', session: browserKey('default') }, + browserKey('default'), ); expect(result.ok).toBe(true); @@ -1092,7 +1105,7 @@ describe('background tab isolation', () => { expect(chrome.tabGroups.update).not.toHaveBeenCalled(); }); - it('keeps site:notebooklm inside its owned automation lease instead of rebinding to a user tab', async () => { + it('keeps adapter:notebooklm inside its owned automation lease instead of rebinding to a user tab', async () => { const { chrome, tabs } = createChromeMock(); tabs[0].url = 'https://notebooklm.google.com/'; tabs[0].title = 'NotebookLM Home'; @@ -1101,12 +1114,12 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:notebooklm', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); - const tabId = await mod.__test__.resolveTabId(undefined, 'site:notebooklm'); + const tabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter')); expect(tabId).toBe(1); - expect(mod.__test__.getSession('site:notebooklm')).toEqual(expect.objectContaining({ + expect(mod.__test__.getSession(adapterKey('twitter'))).toEqual(expect.objectContaining({ windowId: 1, })); }); @@ -1119,9 +1132,9 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); - const tabId = await mod.__test__.resolveTabId(1, 'site:twitter'); + const tabId = await mod.__test__.resolveTabId(1, adapterKey('twitter')); // Should have moved tab 1 back to window 1 and reused it expect(chrome.tabs.move).toHaveBeenCalledWith(1, { windowId: 1, index: -1 }); @@ -1137,14 +1150,14 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); // Should still resolve (by finding/creating a tab in the correct window) - const tabId = await mod.__test__.resolveTabId(1, 'site:twitter'); + const tabId = await mod.__test__.resolveTabId(1, adapterKey('twitter')); expect(typeof tabId).toBe('number'); }); - it('idle timeout releases the automation lease for site:notebooklm', async () => { + it('idle timeout releases the automation lease for adapter:notebooklm', async () => { const { chrome, tabs } = createChromeMock(); tabs[0].url = 'https://notebooklm.google.com/'; tabs[0].title = 'NotebookLM Home'; @@ -1154,124 +1167,123 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('site:notebooklm', 1); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); - mod.__test__.resetWindowIdleTimer('site:notebooklm'); + mod.__test__.resetWindowIdleTimer(adapterKey('twitter')); await vi.advanceTimersByTimeAsync(30001); expect(chrome.windows.remove).not.toHaveBeenCalled(); - expect(mod.__test__.getSession('site:notebooklm')).toBeNull(); + expect(mod.__test__.getSession(adapterKey('twitter'))).toBeNull(); }); - it('uses 10-minute timeout for browser:* workspaces', async () => { + it('uses 10-minute timeout for browser:* sessions', async () => { const { chrome } = createChromeMock(); vi.useFakeTimers(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('browser:default', 1); + mod.__test__.setAutomationWindowId(browserKey('default'), 1); - mod.__test__.resetWindowIdleTimer('browser:default'); + mod.__test__.resetWindowIdleTimer(browserKey('default')); // After 30s (adapter timeout), session should still be alive await vi.advanceTimersByTimeAsync(30001); - expect(mod.__test__.getSession('browser:default')).not.toBeNull(); + expect(mod.__test__.getSession(browserKey('default'))).not.toBeNull(); // After 10 min total, session should be cleaned up await vi.advanceTimersByTimeAsync(600000 - 30001); expect(chrome.windows.remove).not.toHaveBeenCalled(); - expect(mod.__test__.getSession('browser:default')).toBeNull(); + expect(mod.__test__.getSession(browserKey('default'))).toBeNull(); }); - it('clears workspaceTimeoutOverrides on idle expiry', async () => { + it('clears sessionTimeoutOverrides on idle expiry', async () => { const { chrome } = createChromeMock(); vi.useFakeTimers(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('browser:test', 1); + mod.__test__.setAutomationWindowId(browserKey('default'), 1); // Set a custom timeout override - mod.__test__.workspaceTimeoutOverrides.set('browser:test', 120_000); - expect(mod.__test__.getIdleTimeout('browser:test')).toBe(120_000); + mod.__test__.sessionTimeoutOverrides.set(browserKey('default'), 120_000); + expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(120_000); // Trigger idle timer with the custom timeout - mod.__test__.resetWindowIdleTimer('browser:test'); + mod.__test__.resetWindowIdleTimer(browserKey('default')); await vi.advanceTimersByTimeAsync(120001); // Override should be cleaned up - expect(mod.__test__.workspaceTimeoutOverrides.has('browser:test')).toBe(false); - expect(mod.__test__.getSession('browser:test')).toBeNull(); + expect(mod.__test__.sessionTimeoutOverrides.has(browserKey('default'))).toBe(false); + expect(mod.__test__.getSession(browserKey('default'))).toBeNull(); // Should fall back to default interactive timeout - expect(mod.__test__.getIdleTimeout('browser:test')).toBe(600_000); + expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(600_000); }); - it('clears workspaceTimeoutOverrides on explicit close', async () => { + it('clears sessionTimeoutOverrides on explicit close', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('browser:close-test', 1); - mod.__test__.workspaceTimeoutOverrides.set('browser:close-test', 300_000); + mod.__test__.setAutomationWindowId(browserKey('default'), 1); + mod.__test__.sessionTimeoutOverrides.set(browserKey('default'), 300_000); const result = await mod.__test__.handleCommand({ id: 'close-1', action: 'close-window', - workspace: 'browser:close-test', + session: browserKey('default'), }); expect(result.ok).toBe(true); - expect(mod.__test__.workspaceTimeoutOverrides.has('browser:close-test')).toBe(false); + expect(mod.__test__.sessionTimeoutOverrides.has(browserKey('default'))).toBe(false); }); - it('applies idleTimeout from command to workspace override', async () => { + it('applies idleTimeout from command to session override', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('browser:custom', 1); + mod.__test__.setAutomationWindowId(browserKey('default'), 1); // Default for browser:* is 10 min - expect(mod.__test__.getIdleTimeout('browser:custom')).toBe(600_000); + expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(600_000); // Send a command with custom idleTimeout (in seconds) await mod.__test__.handleCommand({ id: 'custom-1', action: 'sessions', - workspace: 'browser:custom', + session: browserKey('default'), idleTimeout: 120, }); // Override should now be 120s = 120000ms - expect(mod.__test__.getIdleTimeout('browser:custom')).toBe(120_000); + expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(120_000); }); - it('clears workspaceTimeoutOverrides when user manually closes the automation container', async () => { + it('clears sessionTimeoutOverrides when user manually closes the automation container', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); // Set up a session with window ID 42 and a custom timeout override - mod.__test__.setAutomationWindowId('browser:manual', 42); - mod.__test__.workspaceTimeoutOverrides.set('browser:manual', 180_000); - expect(mod.__test__.getIdleTimeout('browser:manual')).toBe(180_000); + mod.__test__.setAutomationWindowId(browserKey('default'), 42); + mod.__test__.sessionTimeoutOverrides.set(browserKey('default'), 180_000); + expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(180_000); // Simulate user closing the window — invoke the onRemoved listener const onRemovedListener = chrome.windows.onRemoved.addListener.mock.calls[0][0]; await onRemovedListener(42); // Session and override should both be cleaned up - expect(mod.__test__.getSession('browser:manual')).toBeNull(); - expect(mod.__test__.workspaceTimeoutOverrides.has('browser:manual')).toBe(false); + expect(mod.__test__.getSession(browserKey('default'))).toBeNull(); + expect(mod.__test__.sessionTimeoutOverrides.has(browserKey('default'))).toBe(false); // Should fall back to default interactive timeout - expect(mod.__test__.getIdleTimeout('browser:manual')).toBe(600_000); + expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(600_000); }); it('bind does not reach into background windows when the current window has no match', async () => { const { chrome, tabs } = createChromeMock(); - tabs[1].active = false; - tabs[1].windowId = 3; + tabs[1].url = 'chrome://extensions'; vi.stubGlobal('chrome', chrome); const mod = await import('./background'); @@ -1279,47 +1291,34 @@ describe('background tab isolation', () => { const result = await mod.__test__.handleBind({ id: 'bind-current-window-only', action: 'bind', - workspace: 'bound:default', - matchDomain: 'user.example', - }, 'bound:default'); + session: browserKey('default'), + }, browserKey('default')); expect(result).toEqual(expect.objectContaining({ ok: false, errorCode: 'bound_tab_not_found', error: expect.stringContaining('current window'), })); - expect(mod.__test__.getSession('bound:default')).toBeNull(); + expect(mod.__test__.getSession(browserKey('default'))).toBeNull(); }); - it('bind attaches only bound:* workspaces to the matching current tab', async () => { + it('bind attaches the current tab to the named browser session', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - const rejected = await mod.__test__.handleBind({ - id: 'bind-bad', - action: 'bind', - workspace: 'browser:default', - matchDomain: 'user.example', - }, 'browser:default'); - expect(rejected).toEqual(expect.objectContaining({ - ok: false, - errorCode: 'invalid_bind_workspace', - })); - const bound = await mod.__test__.handleBind({ id: 'bind-good', action: 'bind', - workspace: 'bound:default', - matchDomain: 'user.example', - }, 'bound:default'); + session: 'default', + }, browserKey('default')); expect(bound).toEqual(expect.objectContaining({ ok: true, - data: expect.objectContaining({ workspace: 'bound:default', url: 'https://user.example' }), + data: expect.objectContaining({ session: 'default', url: 'https://user.example' }), })); - expect(mod.__test__.getSession('bound:default')).toEqual(expect.objectContaining({ + expect(mod.__test__.getSession(browserKey('default'))).toEqual(expect.objectContaining({ windowId: 2, owned: false, preferredTabId: 2, @@ -1329,28 +1328,27 @@ describe('background tab isolation', () => { expect(chrome.windows.create).not.toHaveBeenCalled(); }); - it('refuses bind when the bound workspace already owns an automation lease', async () => { + it('rebind releases an owned browser lease before binding the current user tab', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setAutomationWindowId('bound:default', 1); + mod.__test__.setAutomationWindowId(browserKey('default'), 1); const result = await mod.__test__.handleBind({ id: 'bind-overwrite', action: 'bind', - workspace: 'bound:default', - matchDomain: 'user.example', - }, 'bound:default'); + session: 'default', + }, browserKey('default')); expect(result).toEqual(expect.objectContaining({ - ok: false, - errorCode: 'invalid_bind_workspace', + ok: true, + data: expect.objectContaining({ session: 'default', url: 'https://user.example' }), })); - expect(chrome.tabs.query).not.toHaveBeenCalled(); - expect(mod.__test__.getSession('bound:default')).toEqual(expect.objectContaining({ - windowId: 1, - owned: true, + expect(mod.__test__.getSession(browserKey('default'))).toEqual(expect.objectContaining({ + windowId: 2, + owned: false, + kind: 'bound', })); }); @@ -1360,14 +1358,14 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setSession('bound:default', { windowId: 2, owned: false, preferredTabId: 2 }); + mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 }); - expect(mod.__test__.getIdleTimeout('bound:default')).toBe(-1); - mod.__test__.resetWindowIdleTimer('bound:default'); + expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(-1); + mod.__test__.resetWindowIdleTimer(browserKey('default')); await vi.advanceTimersByTimeAsync(60 * 60 * 1000); expect(chrome.windows.remove).not.toHaveBeenCalled(); - expect(mod.__test__.getSession('bound:default')).not.toBeNull(); + expect(mod.__test__.getSession(browserKey('default'))).not.toBeNull(); }); it('explicit close on a borrowed bound session detaches without touching tabs or windows', async () => { @@ -1375,19 +1373,19 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setSession('bound:default', { windowId: 2, owned: false, preferredTabId: 2 }); + mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 }); const result = await mod.__test__.handleCommand({ id: 'bound-close', action: 'close-window', - workspace: 'bound:default', + session: browserKey('default'), }); expect(result).toEqual(expect.objectContaining({ ok: true })); expect(chrome.tabs.remove).not.toHaveBeenCalled(); expect(chrome.tabs.update).not.toHaveBeenCalled(); expect(chrome.windows.remove).not.toHaveBeenCalled(); - expect(mod.__test__.getSession('bound:default')).toBeNull(); + expect(mod.__test__.getSession(browserKey('default'))).toBeNull(); }); it('cleans borrowed sessions when the bound tab is closed', async () => { @@ -1395,12 +1393,12 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setSession('bound:default', { windowId: 2, owned: false, preferredTabId: 2 }); + mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 }); const onRemovedListener = chrome.tabs.onRemoved.addListener.mock.calls[0][0]; onRemovedListener(2); - expect(mod.__test__.getSession('bound:default')).toBeNull(); + expect(mod.__test__.getSession(browserKey('default'))).toBeNull(); expect(chrome.windows.remove).not.toHaveBeenCalled(); }); @@ -1409,12 +1407,12 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setSession('bound:default', { windowId: 2, owned: false, preferredTabId: 999 }); + mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 999 }); const result = await mod.__test__.handleCommand({ id: 'bound-exec-gone', action: 'exec', - workspace: 'bound:default', + session: browserKey('default'), code: 'document.title', }); @@ -1431,12 +1429,12 @@ describe('background tab isolation', () => { vi.stubGlobal('chrome', chrome); const mod = await import('./background'); - mod.__test__.setSession('bound:default', { windowId: 2, owned: false, preferredTabId: 2 }); + mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 }); const result = await mod.__test__.handleCommand({ id: 'bound-exec-undebuggable', action: 'exec', - workspace: 'bound:default', + session: browserKey('default'), code: 'document.title', }); @@ -1447,36 +1445,39 @@ describe('background tab isolation', () => { expect(chrome.windows.create).not.toHaveBeenCalled(); }); - it('blocks navigation and tab mutation on borrowed bound sessions by default', async () => { + it('allows navigation but blocks tab mutation on borrowed sessions', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); + vi.doMock('./cdp', () => ({ + registerListeners: vi.fn(), + registerFrameTracking: vi.fn(), + hasActiveNetworkCapture: vi.fn(() => false), + detach: vi.fn(async () => {}), + })); const mod = await import('./background'); - mod.__test__.setSession('bound:default', { windowId: 2, owned: false, preferredTabId: 2 }); + mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 }); const nav = await mod.__test__.handleCommand({ id: 'bound-nav', action: 'navigate', - workspace: 'bound:default', + session: browserKey('default'), url: 'https://other.example', }); const tabNew = await mod.__test__.handleCommand({ id: 'bound-tab-new', action: 'tabs', - workspace: 'bound:default', + session: browserKey('default'), op: 'new', url: 'https://other.example', }); - expect(nav).toEqual(expect.objectContaining({ - ok: false, - errorCode: 'bound_navigation_blocked', - })); + expect(nav).toEqual(expect.objectContaining({ ok: true })); expect(tabNew).toEqual(expect.objectContaining({ ok: false, errorCode: 'bound_tab_mutation_blocked', })); - expect(chrome.tabs.update).not.toHaveBeenCalledWith(2, expect.objectContaining({ url: 'https://other.example' })); + expect(chrome.tabs.update).toHaveBeenCalledWith(2, expect.objectContaining({ url: 'https://other.example' })); expect(chrome.tabs.create).not.toHaveBeenCalled(); }); }); diff --git a/extension/src/background.ts b/extension/src/background.ts index ed0516ea7..22e5dd641 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -165,12 +165,10 @@ function scheduleReconnect(): void { } // ─── Browser target leases ─────────────────────────────────────────── -// OpenCLI does not model workspace identity as a Chrome window. A workspace -// owns or borrows a tab lease; owned leases live in either the interactive -// browser window or the background automation window, while borrowed leases -// point at user-owned tabs. -// Interactive workspaces (browser:*, operate:*) get a longer timeout (10 min) -// since users type commands manually; adapter workspaces keep a short 30s timeout. +// A browser session owns or borrows a tab lease. Owned leases live in either +// the interactive browser window or the background adapter window; bound leases +// point at user-owned tabs. Lease behavior is stored as metadata instead of +// encoded in session-name prefixes. type BrowserContextId = string; type LeaseOwnership = 'owned' | 'borrowed'; @@ -178,8 +176,13 @@ type LeaseLifecycle = 'ephemeral' | 'persistent' | 'pinned'; type WindowRole = 'interactive' | 'automation' | 'borrowed-user'; type OwnedWindowRole = Exclude; type WindowMode = 'foreground' | 'background'; +type BrowserSurface = 'browser' | 'adapter'; +type LeaseKind = 'owned' | 'bound'; type TargetLease = { + session: string; + surface: BrowserSurface; + kind: LeaseKind; windowId: number; idleTimer: ReturnType | null; idleDeadlineAt: number; @@ -231,48 +234,77 @@ class CommandFailure extends Error { } } -/** Per-workspace custom timeout overrides set via command.idleTimeout */ -const workspaceTimeoutOverrides = new Map(); -const workspaceWindowModeOverrides = new Map(); +/** Per-session custom timeout overrides set via command.idleTimeout */ +const sessionTimeoutOverrides = new Map(); +const sessionWindowModeOverrides = new Map(); +const LEASE_KEY_SEPARATOR = '\u0000'; -function getIdleTimeout(workspace: string): number { - if (workspace.startsWith('bound:')) return IDLE_TIMEOUT_NONE; - const override = workspaceTimeoutOverrides.get(workspace); - if (override !== undefined) return override; - if (workspace.startsWith('browser:') || workspace.startsWith('operate:')) { - return IDLE_TIMEOUT_INTERACTIVE; +function getLeaseKey(session: string, surface: BrowserSurface): string { + return `${surface}${LEASE_KEY_SEPARATOR}${encodeURIComponent(session)}`; +} + +function getSessionName(session?: string): string { + const raw = session?.trim(); + if (!raw) throw new CommandFailure( + 'session_required', + 'Browser session is required.', + 'Pass --session with opencli browser commands.', + ); + return raw.includes(LEASE_KEY_SEPARATOR) ? getSessionFromKey(raw) : raw; +} + +function getCommandSurface(cmd: Pick): BrowserSurface { + if (typeof cmd.session === 'string' && cmd.session.includes(LEASE_KEY_SEPARATOR)) { + return getSurfaceFromKey(cmd.session); } - return IDLE_TIMEOUT_DEFAULT; + return cmd.surface === 'adapter' ? 'adapter' : 'browser'; +} + +function getSurfaceFromKey(key: string): BrowserSurface { + return key.split(LEASE_KEY_SEPARATOR, 1)[0] === 'adapter' ? 'adapter' : 'browser'; } -function getWorkspaceKey(workspace?: string): string { - return workspace?.trim() || 'default'; +function getSessionFromKey(key: string): string { + const idx = key.indexOf(LEASE_KEY_SEPARATOR); + if (idx === -1) return key; + try { + return decodeURIComponent(key.slice(idx + 1)); + } catch { + return key.slice(idx + 1); + } +} + +function getIdleTimeout(key: string): number { + const session = automationSessions.get(key); + if (session?.kind === 'bound') return IDLE_TIMEOUT_NONE; + const override = sessionTimeoutOverrides.get(key); + if (override !== undefined) return override; + return getSurfaceFromKey(key) === 'browser' ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT; } -function getLeaseLifecycle(workspace: string): LeaseLifecycle { - if (workspace.startsWith('bound:')) return 'pinned'; - if (workspace.startsWith('browser:') || workspace.startsWith('operate:')) return 'persistent'; - return 'ephemeral'; +function getLeaseLifecycle(key: string, kind: LeaseKind): LeaseLifecycle { + if (kind === 'bound') return 'pinned'; + return getSurfaceFromKey(key) === 'browser' ? 'persistent' : 'ephemeral'; } -function getOwnedWindowRole(workspace: string): OwnedWindowRole { - return (workspace.startsWith('browser:') || workspace.startsWith('operate:')) ? 'interactive' : 'automation'; +function getOwnedWindowRole(key: string): OwnedWindowRole { + return getSurfaceFromKey(key) === 'browser' ? 'interactive' : 'automation'; } -function getWindowRole(workspace: string, ownership: LeaseOwnership): WindowRole { - return ownership === 'borrowed' ? 'borrowed-user' : getOwnedWindowRole(workspace); +function getWindowRole(key: string, ownership: LeaseOwnership): WindowRole { + return ownership === 'borrowed' ? 'borrowed-user' : getOwnedWindowRole(key); } -function getWindowMode(workspace: string): WindowMode { - return workspaceWindowModeOverrides.get(workspace) - ?? (getOwnedWindowRole(workspace) === 'interactive' ? 'foreground' : 'background'); +function getWindowMode(key: string): WindowMode { + return sessionWindowModeOverrides.get(key) + ?? (getOwnedWindowRole(key) === 'interactive' ? 'foreground' : 'background'); } -function makeAlarmName(workspace: string): string { - return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(workspace)}`; +function makeAlarmName(leaseKey: string): string { + return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`; } -function workspaceFromAlarmName(name: string): string | null { +function leaseKeyFromAlarmName(name: string): string | null { if (!name.startsWith(LEASE_IDLE_ALARM_PREFIX)) return null; try { return decodeURIComponent(name.slice(LEASE_IDLE_ALARM_PREFIX.length)); @@ -288,7 +320,7 @@ function withLeaseMutation(fn: () => Promise): Promise { } function makeSession( - workspace: string, + key: string, session: Omit, ): Omit { const ownership = session.owned ? 'owned' : 'borrowed'; @@ -296,8 +328,8 @@ function makeSession( ...session, contextId: currentContextId, ownership, - lifecycle: getLeaseLifecycle(workspace), - windowRole: getWindowRole(workspace, ownership), + lifecycle: getLeaseLifecycle(key, session.kind), + windowRole: getWindowRole(key, ownership), }; } @@ -359,8 +391,11 @@ async function writeRegistry(registry: StoredRegistry): Promise { async function persistRuntimeState(): Promise { const leases: Record = {}; - for (const [workspace, session] of automationSessions.entries()) { - leases[workspace] = { + for (const [leaseKey, session] of automationSessions.entries()) { + leases[leaseKey] = { + session: session.session, + surface: session.surface, + kind: session.kind, windowId: session.windowId, owned: session.owned, preferredTabId: session.preferredTabId, @@ -389,8 +424,8 @@ async function persistRuntimeState(): Promise { }); } -function scheduleIdleAlarm(workspace: string, timeout: number): void { - const alarmName = makeAlarmName(workspace); +function scheduleIdleAlarm(leaseKey: string, timeout: number): void { + const alarmName = makeAlarmName(leaseKey); try { if (timeout > 0) { chrome.alarms?.create?.(alarmName, { when: Date.now() + timeout }); @@ -411,22 +446,22 @@ async function safeDetach(tabId: number): Promise { } } -async function removeWorkspaceSession(workspace: string): Promise { - const existing = automationSessions.get(workspace); +async function removeLeaseSession(leaseKey: string): Promise { + const existing = automationSessions.get(leaseKey); if (existing?.idleTimer) clearTimeout(existing.idleTimer); - automationSessions.delete(workspace); - workspaceTimeoutOverrides.delete(workspace); - workspaceWindowModeOverrides.delete(workspace); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); } -function resetWindowIdleTimer(workspace: string): void { - const session = automationSessions.get(workspace); +function resetWindowIdleTimer(leaseKey: string): void { + const session = automationSessions.get(leaseKey); if (!session) return; if (session.idleTimer) clearTimeout(session.idleTimer); - const timeout = getIdleTimeout(workspace); - scheduleIdleAlarm(workspace, timeout); + const timeout = getIdleTimeout(leaseKey); + scheduleIdleAlarm(leaseKey, timeout); if (timeout <= 0) { session.idleTimer = null; session.idleDeadlineAt = 0; @@ -436,7 +471,7 @@ function resetWindowIdleTimer(workspace: string): void { session.idleDeadlineAt = Date.now() + timeout; void persistRuntimeState(); session.idleTimer = setTimeout(async () => { - await releaseWorkspaceLease(workspace, 'idle timeout'); + await releaseLease(leaseKey, 'idle timeout'); }, timeout); } @@ -494,7 +529,7 @@ async function ensureOwnedContainerTabGroup(role: OwnedWindowRole, windowId: num * * First-principles model: * - BrowserContext is the user's default Chrome profile. - * - Workspace identity maps to a TargetLease (usually a tab), not a window. + * - Session identity maps to a TargetLease (usually a tab), not a window. * - Browser commands and adapters use separate owned windows so foreground * interactive work cannot drag background adapter automation into view. */ @@ -600,14 +635,14 @@ function initialTabIsAvailable(tabId: number | undefined): tabId is number { return true; } -async function createOwnedTabLease(workspace: string, initialUrl?: string): Promise { - return withLeaseMutation(() => createOwnedTabLeaseUnlocked(workspace, initialUrl)); +async function createOwnedTabLease(leaseKey: string, initialUrl?: string): Promise { + return withLeaseMutation(() => createOwnedTabLeaseUnlocked(leaseKey, initialUrl)); } -async function createOwnedTabLeaseUnlocked(workspace: string, initialUrl?: string): Promise { +async function createOwnedTabLeaseUnlocked(leaseKey: string, initialUrl?: string): Promise { const targetUrl = (initialUrl && isSafeNavigationUrl(initialUrl)) ? initialUrl : BLANK_PAGE; - const role = getOwnedWindowRole(workspace); - const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(workspace)); + const role = getOwnedWindowRole(leaseKey); + const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(leaseKey)); let tab: chrome.tabs.Tab; if (initialTabIsAvailable(initialTabId)) { @@ -623,35 +658,31 @@ async function createOwnedTabLeaseUnlocked(workspace: string, initialUrl?: strin if (!tab.id) throw new Error('Failed to create tab lease in automation container'); await ensureOwnedContainerTabGroup(role, windowId, [tab.id]); - setWorkspaceSession(workspace, { + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: 'owned', windowId, owned: true, preferredTabId: tab.id, }); - resetWindowIdleTimer(workspace); + resetWindowIdleTimer(leaseKey); return { tabId: tab.id, tab }; } /** Get or create the dedicated automation container window. - * This compatibility helper returns the shared owned container. Workspaces + * This compatibility helper returns the shared owned container. Leases * lease tabs inside it instead of owning separate windows. */ -async function getAutomationWindow(workspace: string, initialUrl?: string): Promise { - if (workspace.startsWith('bound:') && !automationSessions.has(workspace)) { - throw new CommandFailure( - 'bound_session_missing', - `Bound workspace "${workspace}" is not attached to a tab. Run "opencli browser bind --workspace ${workspace}" first.`, - 'Run bind again, then retry the browser command.', - ); - } - // Check if our window is still alive - const existing = automationSessions.get(workspace); +async function getAutomationWindow(leaseKey: string, initialUrl?: string): Promise { + // Check if our window is still alive. + const existing = automationSessions.get(leaseKey); if (existing) { if (!existing.owned) { throw new CommandFailure( 'bound_window_operation_blocked', - `Workspace "${workspace}" is bound to a user tab and does not own an automation tab lease.`, - 'Use commands that operate on the bound tab, or unbind and use an automation workspace.', + `Session "${existing.session}" is bound to a user tab and does not own an OpenCLI tab lease.`, + 'Use page commands on the bound tab, or unbind the session first.', ); } try { @@ -664,12 +695,12 @@ async function getAutomationWindow(workspace: string, initialUrl?: string): Prom return existing.windowId; } catch { // Tab/window was closed by user - await removeWorkspaceSession(workspace); + await removeLeaseSession(leaseKey); } } - const role = getOwnedWindowRole(workspace); - return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(workspace))).windowId; + const role = getOwnedWindowRole(leaseKey); + return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(leaseKey))).windowId; } // Clean up when an owned container window is closed @@ -680,14 +711,14 @@ chrome.windows.onRemoved.addListener(async (windowId) => { container.groupId = null; } } - for (const [workspace, session] of automationSessions.entries()) { + for (const [leaseKey, session] of automationSessions.entries()) { if (session.windowId === windowId) { - console.log(`[opencli] Automation container closed (${workspace})`); + console.log(`[opencli] ${session.surface} container closed (session=${session.session})`); if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - workspaceTimeoutOverrides.delete(workspace); - workspaceWindowModeOverrides.delete(workspace); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); } } await persistRuntimeState(); @@ -696,13 +727,13 @@ chrome.windows.onRemoved.addListener(async (windowId) => { // Evict identity mappings when tabs are closed chrome.tabs.onRemoved.addListener(async (tabId) => { identity.evictTab(tabId); - for (const [workspace, session] of automationSessions.entries()) { + for (const [leaseKey, session] of automationSessions.entries()) { if (session.preferredTabId === tabId) { if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - workspaceTimeoutOverrides.delete(workspace); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); - console.log(`[opencli] Workspace ${workspace} lease detached from tab ${tabId} (tab closed)`); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); } } await persistRuntimeState(); @@ -746,8 +777,8 @@ initialize(); chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === 'keepalive') void connect(); - const workspace = workspaceFromAlarmName(alarm.name); - if (workspace) await releaseWorkspaceLease(workspace, 'idle alarm'); + const leaseKey = leaseKeyFromAlarmName(alarm.name); + if (leaseKey) await releaseLease(leaseKey, 'idle alarm'); }); // ─── Popup status API ─────────────────────────────────────────────── @@ -795,48 +826,50 @@ async function fetchDaemonVersion(): Promise { // ─── Command dispatcher ───────────────────────────────────────────── async function handleCommand(cmd: Command): Promise { - const workspace = getWorkspaceKey(cmd.workspace); - if (!workspace.startsWith('bound:') && (cmd.windowMode === 'foreground' || cmd.windowMode === 'background')) { - workspaceWindowModeOverrides.set(workspace, cmd.windowMode); + const session = getSessionName(cmd.session); + const surface = getCommandSurface(cmd); + const leaseKey = getLeaseKey(session, surface); + if (cmd.windowMode === 'foreground' || cmd.windowMode === 'background') { + sessionWindowModeOverrides.set(leaseKey, cmd.windowMode); } // Apply custom idle timeout if specified in the command if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { - workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1000); + sessionTimeoutOverrides.set(leaseKey, cmd.idleTimeout * 1000); } // Reset idle timer on every command (window stays alive while active) - resetWindowIdleTimer(workspace); + resetWindowIdleTimer(leaseKey); try { switch (cmd.action) { case 'exec': - return await handleExec(cmd, workspace); + return await handleExec(cmd, leaseKey); case 'navigate': - return await handleNavigate(cmd, workspace); + return await handleNavigate(cmd, leaseKey); case 'tabs': - return await handleTabs(cmd, workspace); + return await handleTabs(cmd, leaseKey); case 'cookies': return await handleCookies(cmd); case 'screenshot': - return await handleScreenshot(cmd, workspace); + return await handleScreenshot(cmd, leaseKey); case 'close-window': - return await handleCloseWindow(cmd, workspace); + return await handleCloseWindow(cmd, leaseKey); case 'cdp': - return await handleCdp(cmd, workspace); + return await handleCdp(cmd, leaseKey); case 'sessions': return await handleSessions(cmd); case 'set-file-input': - return await handleSetFileInput(cmd, workspace); + return await handleSetFileInput(cmd, leaseKey); case 'insert-text': - return await handleInsertText(cmd, workspace); + return await handleInsertText(cmd, leaseKey); case 'bind': - return await handleBind(cmd, workspace); + return await handleBind(cmd, leaseKey); case 'network-capture-start': - return await handleNetworkCaptureStart(cmd, workspace); + return await handleNetworkCaptureStart(cmd, leaseKey); case 'network-capture-read': - return await handleNetworkCaptureRead(cmd, workspace); + return await handleNetworkCaptureRead(cmd, leaseKey); case 'wait-download': return await handleWaitDownload(cmd); case 'frames': - return await handleFrames(cmd, workspace); + return await handleFrames(cmd, leaseKey); default: return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; } @@ -886,30 +919,6 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } -function matchesDomain(url: string | undefined, domain: string): boolean { - if (!url) return false; - try { - const parsed = new URL(url); - return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); - } catch { - return false; - } -} - -function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean { - if (!tab.id || !isDebuggableUrl(tab.url)) return false; - if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; - if (cmd.matchPathPrefix) { - try { - const parsed = new URL(tab.url!); - if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false; - } catch { - return false; - } - } - return true; -} - function getUrlOrigin(url: string | undefined): string | null { if (!url) return null; try { @@ -951,15 +960,15 @@ function enumerateCrossOriginFrames(tree: any): Array<{ index: number; frameId: return frames; } -function setWorkspaceSession( - workspace: string, +function setLeaseSession( + leaseKey: string, session: Omit, ): void { - const existing = automationSessions.get(workspace); + const existing = automationSessions.get(leaseKey); if (existing?.idleTimer) clearTimeout(existing.idleTimer); - const timeout = getIdleTimeout(workspace); - automationSessions.set(workspace, { - ...makeSession(workspace, session), + const timeout = getIdleTimeout(leaseKey); + automationSessions.set(leaseKey, { + ...makeSession(leaseKey, session), idleTimer: null, idleDeadlineAt: timeout <= 0 ? 0 : Date.now() + timeout, }); @@ -978,11 +987,11 @@ async function resolveCommandTabId(cmd: Command): Promise { type ResolvedTab = { tabId: number; tab: chrome.tabs.Tab | null }; /** - * Resolve target tab for the workspace lease, returning both the tabId and + * Resolve target tab for the session lease, returning both the tabId and * the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). */ -async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string): Promise { - const existingSession = automationSessions.get(workspace); +async function resolveTab(tabId: number | undefined, leaseKey: string, initialUrl?: string): Promise { + const existingSession = automationSessions.get(leaseKey); // Even when an explicit tabId is provided, validate it is still debuggable. if (tabId !== undefined) { try { @@ -996,8 +1005,8 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU throw new CommandFailure( matchesSession ? 'bound_tab_not_debuggable' : 'bound_tab_mismatch', matchesSession - ? `Bound tab for workspace "${workspace}" is not debuggable (${tab.url ?? 'unknown URL'}).` - : `Target tab is not the tab bound to workspace "${workspace}".`, + ? `Bound tab for session "${session.session}" is not debuggable (${tab.url ?? 'unknown URL'}).` + : `Target tab is not the tab bound to session "${session.session}".`, 'Run "opencli browser bind" again on a debuggable http(s) tab.', ); } @@ -1020,10 +1029,10 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU } catch (err) { if (err instanceof CommandFailure) throw err; if (existingSession && !existingSession.owned) { - automationSessions.delete(workspace); + automationSessions.delete(leaseKey); throw new CommandFailure( 'bound_tab_gone', - `Bound tab for workspace "${workspace}" no longer exists.`, + `Bound tab for session "${existingSession.session}" no longer exists.`, 'Run "opencli browser bind" again, then retry the command.', ); } @@ -1040,34 +1049,30 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU if (!session.owned) { throw new CommandFailure( 'bound_tab_not_debuggable', - `Bound tab for workspace "${workspace}" is not debuggable (${preferredTab.url ?? 'unknown URL'}).`, + `Bound tab for session "${session.session}" is not debuggable (${preferredTab.url ?? 'unknown URL'}).`, 'Switch the tab to an http(s) page or run "opencli browser bind" on another tab.', ); } } catch (err) { if (err instanceof CommandFailure) throw err; - await removeWorkspaceSession(workspace); + await removeLeaseSession(leaseKey); if (!session.owned) { throw new CommandFailure( 'bound_tab_gone', - `Bound tab for workspace "${workspace}" no longer exists.`, + `Bound tab for session "${session.session}" no longer exists.`, 'Run "opencli browser bind" again, then retry the command.', ); } - return createOwnedTabLease(workspace, initialUrl); + return createOwnedTabLease(leaseKey, initialUrl); } } - if (!existingSession && workspace.startsWith('bound:')) { - await getAutomationWindow(workspace, initialUrl); // throws bound_session_missing - } - if (!existingSession || (existingSession.owned && existingSession.preferredTabId === null)) { - return createOwnedTabLease(workspace, initialUrl); + return createOwnedTabLease(leaseKey, initialUrl); } // Get (or create) the dedicated automation container - const windowId = await getAutomationWindow(workspace, initialUrl); + const windowId = await getAutomationWindow(leaseKey, initialUrl); // Prefer an existing debuggable tab const tabs = await chrome.tabs.query({ windowId }); @@ -1097,45 +1102,49 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU /** Build a page-scoped success result with targetId resolved from tabId */ async function pageScopedResult(id: string, tabId: number, data?: unknown): Promise { const page = await identity.resolveTargetId(tabId); - return { id, ok: true, data, page }; + const lease = [...automationSessions.values()].find((session) => session.preferredTabId === tabId); + const scopedData = data && typeof data === 'object' && !Array.isArray(data) + ? { session: lease?.session, ...(data as Record) } + : { session: lease?.session, data }; + return { id, ok: true, data: scopedData, page }; } /** Convenience wrapper returning just the tabId (used by most handlers) */ -async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise { - const resolved = await resolveTab(tabId, workspace, initialUrl); +async function resolveTabId(tabId: number | undefined, leaseKey: string, initialUrl?: string): Promise { + const resolved = await resolveTab(tabId, leaseKey, initialUrl); return resolved.tabId; } -async function listAutomationTabs(workspace: string): Promise { - const session = automationSessions.get(workspace); +async function listAutomationTabs(leaseKey: string): Promise { + const session = automationSessions.get(leaseKey); if (!session) return []; if (session.preferredTabId !== null) { try { return [await chrome.tabs.get(session.preferredTabId)]; } catch { - automationSessions.delete(workspace); + automationSessions.delete(leaseKey); return []; } } try { return await chrome.tabs.query({ windowId: session.windowId }); } catch { - automationSessions.delete(workspace); + automationSessions.delete(leaseKey); return []; } } -async function listAutomationWebTabs(workspace: string): Promise { - const tabs = await listAutomationTabs(workspace); +async function listAutomationWebTabs(leaseKey: string): Promise { + const tabs = await listAutomationTabs(leaseKey); return tabs.filter((tab) => isDebuggableUrl(tab.url)); } -async function handleExec(cmd: Command, workspace: string): Promise { +async function handleExec(cmd: Command, leaseKey: string): Promise { if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { - const aggressive = workspace.startsWith('browser:') || workspace.startsWith('operate:'); + const aggressive = getSurfaceFromKey(leaseKey) === 'browser'; if (cmd.frameIndex != null) { const tree = await executor.getFrameTree(tabId); const frames = enumerateCrossOriginFrames(tree); @@ -1152,9 +1161,9 @@ async function handleExec(cmd: Command, workspace: string): Promise { } } -async function handleFrames(cmd: Command, workspace: string): Promise { +async function handleFrames(cmd: Command, leaseKey: string): Promise { const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { const tree = await executor.getFrameTree(tabId); return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; @@ -1163,24 +1172,14 @@ async function handleFrames(cmd: Command, workspace: string): Promise { } } -async function handleNavigate(cmd: Command, workspace: string): Promise { +async function handleNavigate(cmd: Command, leaseKey: string): Promise { if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' }; if (!isSafeNavigationUrl(cmd.url)) { return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; } - const session = automationSessions.get(workspace); - if (session && !session.owned && cmd.allowBoundNavigation !== true) { - return { - id: cmd.id, - ok: false, - errorCode: 'bound_navigation_blocked', - error: `Workspace "${workspace}" is bound to a user tab; navigation is blocked by default.`, - errorHint: 'Pass --allow-navigate-bound only if you intentionally want to navigate the bound tab.', - }; - } // Pass target URL so that first-time window creation can start on the right domain const cmdTabId = await resolveCommandTabId(cmd); - const resolved = await resolveTab(cmdTabId, workspace, cmd.url); + const resolved = await resolveTab(cmdTabId, leaseKey, cmd.url); const tabId = resolved.tabId; const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); @@ -1259,16 +1258,7 @@ async function handleNavigate(cmd: Command, workspace: string): Promise // Post-navigation drift detection: if the tab moved to another window // during navigation (e.g. a tab-management extension regrouped it), // try to move it back to maintain session isolation. - const postNavigationSession = automationSessions.get(workspace); - if (postNavigationSession?.owned === false && tab.windowId !== postNavigationSession.windowId) { - return { - id: cmd.id, - ok: false, - errorCode: 'bound_tab_moved', - error: `Bound tab for workspace "${workspace}" moved to another window during navigation.`, - errorHint: 'Run "opencli browser bind" again on the intended tab.', - }; - } + const postNavigationSession = automationSessions.get(leaseKey); if (postNavigationSession && tab.windowId !== postNavigationSession.windowId) { console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${postNavigationSession.windowId}`); try { @@ -1282,20 +1272,20 @@ async function handleNavigate(cmd: Command, workspace: string): Promise return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut }); } -async function handleTabs(cmd: Command, workspace: string): Promise { - const session = automationSessions.get(workspace); +async function handleTabs(cmd: Command, leaseKey: string): Promise { + const session = automationSessions.get(leaseKey); if (session && !session.owned && cmd.op !== 'list') { return { id: cmd.id, ok: false, errorCode: 'bound_tab_mutation_blocked', - error: `Workspace "${workspace}" is bound to a user tab; tab mutation is blocked by default.`, - errorHint: 'Use an automation workspace for tab new/select/close, or unbind first.', + error: `Session "${session.session}" is bound to a user tab; tab new/select/close requires an owned OpenCLI session.`, + errorHint: 'Unbind the session first, or use a different session for owned OpenCLI tabs.', }; } switch (cmd.op) { case 'list': { - const tabs = await listAutomationWebTabs(workspace); + const tabs = await listAutomationWebTabs(leaseKey); const data = await Promise.all(tabs.map(async (t, i) => { let page: string | undefined; try { page = t.id ? await identity.resolveTargetId(t.id) : undefined; } catch { /* skip */ } @@ -1307,31 +1297,34 @@ async function handleTabs(cmd: Command, workspace: string): Promise { if (cmd.url && !isSafeNavigationUrl(cmd.url)) { return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; } - if (!automationSessions.has(workspace)) { - const created = await createOwnedTabLease(workspace, cmd.url); + if (!automationSessions.has(leaseKey)) { + const created = await createOwnedTabLease(leaseKey, cmd.url); return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url }); } - const windowId = await getAutomationWindow(workspace); + const windowId = await getAutomationWindow(leaseKey); const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); if (!tab.id) return { id: cmd.id, ok: false, error: 'Failed to create tab' }; - await ensureOwnedContainerTabGroup(getOwnedWindowRole(workspace), windowId, [tab.id]); - setWorkspaceSession(workspace, { + await ensureOwnedContainerTabGroup(getOwnedWindowRole(leaseKey), windowId, [tab.id]); + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: 'owned', windowId: tab.windowId, owned: true, preferredTabId: tab.id, }); - resetWindowIdleTimer(workspace); + resetWindowIdleTimer(leaseKey); return pageScopedResult(cmd.id, tab.id, { url: tab.url }); } case 'close': { if (cmd.index !== undefined) { - const tabs = await listAutomationWebTabs(workspace); + const tabs = await listAutomationWebTabs(leaseKey); const target = tabs[cmd.index]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; const closedPage = await identity.resolveTargetId(target.id).catch(() => undefined); - const currentSession = automationSessions.get(workspace); + const currentSession = automationSessions.get(leaseKey); if (currentSession?.preferredTabId === target.id) { - await releaseWorkspaceLease(workspace, 'tab close'); + await releaseLease(leaseKey, 'tab close'); } else { await safeDetach(target.id); await chrome.tabs.remove(target.id); @@ -1339,11 +1332,11 @@ async function handleTabs(cmd: Command, workspace: string): Promise { return { id: cmd.id, ok: true, data: { closed: closedPage } }; } const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); const closedPage = await identity.resolveTargetId(tabId).catch(() => undefined); - const currentSession = automationSessions.get(workspace); + const currentSession = automationSessions.get(leaseKey); if (currentSession?.preferredTabId === tabId) { - await releaseWorkspaceLease(workspace, 'tab close'); + await releaseLease(leaseKey, 'tab close'); } else { await safeDetach(tabId); await chrome.tabs.remove(tabId); @@ -1355,7 +1348,7 @@ async function handleTabs(cmd: Command, workspace: string): Promise { return { id: cmd.id, ok: false, error: 'Missing index or page' }; const cmdTabId = await resolveCommandTabId(cmd); if (cmdTabId !== undefined) { - const session = automationSessions.get(workspace); + const session = automationSessions.get(leaseKey); let tab: chrome.tabs.Tab; try { tab = await chrome.tabs.get(cmdTabId); @@ -1368,7 +1361,7 @@ async function handleTabs(cmd: Command, workspace: string): Promise { await chrome.tabs.update(cmdTabId, { active: true }); return pageScopedResult(cmd.id, cmdTabId, { selected: true }); } - const tabs = await listAutomationWebTabs(workspace); + const tabs = await listAutomationWebTabs(leaseKey); const target = tabs[cmd.index!]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; await chrome.tabs.update(target.id, { active: true }); @@ -1399,9 +1392,9 @@ async function handleCookies(cmd: Command): Promise { return { id: cmd.id, ok: true, data }; } -async function handleScreenshot(cmd: Command, workspace: string): Promise { +async function handleScreenshot(cmd: Command, leaseKey: string): Promise { const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { const data = await executor.screenshot(tabId, { format: cmd.format, @@ -1446,15 +1439,15 @@ const CDP_ALLOWLIST = new Set([ 'Emulation.clearDeviceMetricsOverride', ]); -async function handleCdp(cmd: Command, workspace: string): Promise { +async function handleCdp(cmd: Command, leaseKey: string): Promise { if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: 'Missing cdpMethod' }; if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; } const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { - const aggressive = workspace.startsWith('browser:') || workspace.startsWith('operate:'); + const aggressive = getSurfaceFromKey(leaseKey) === 'browser'; await executor.ensureAttached(tabId, aggressive); const params = cmd.cdpParams ?? {}; const routeFrameId = typeof params.frameId === 'string' && params.sessionId === 'target' @@ -1480,17 +1473,18 @@ function stripOpenCliFrameRoutingParams(params: Record, stripFr return rest; } -async function handleCloseWindow(cmd: Command, workspace: string): Promise { - await releaseWorkspaceLease(workspace, 'explicit close'); - return { id: cmd.id, ok: true, data: { closed: true, workspace } }; +async function handleCloseWindow(cmd: Command, leaseKey: string): Promise { + const sessionName = automationSessions.get(leaseKey)?.session ?? getSessionFromKey(leaseKey); + await releaseLease(leaseKey, 'explicit close'); + return { id: cmd.id, ok: true, data: { closed: true, session: sessionName } }; } -async function handleSetFileInput(cmd: Command, workspace: string): Promise { +async function handleSetFileInput(cmd: Command, leaseKey: string): Promise { if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { return { id: cmd.id, ok: false, error: 'Missing or empty files array' }; } const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { await executor.setFileInputFiles(tabId, cmd.files, cmd.selector); return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); @@ -1499,12 +1493,12 @@ async function handleSetFileInput(cmd: Command, workspace: string): Promise { +async function handleInsertText(cmd: Command, leaseKey: string): Promise { if (typeof cmd.text !== 'string') { return { id: cmd.id, ok: false, error: 'Missing text payload' }; } const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { await executor.insertText(tabId, cmd.text); return pageScopedResult(cmd.id, tabId, { inserted: true }); @@ -1513,9 +1507,9 @@ async function handleInsertText(cmd: Command, workspace: string): Promise { +async function handleNetworkCaptureStart(cmd: Command, leaseKey: string): Promise { const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { await executor.startNetworkCapture(tabId, cmd.pattern); return pageScopedResult(cmd.id, tabId, { started: true }); @@ -1524,9 +1518,9 @@ async function handleNetworkCaptureStart(cmd: Command, workspace: string): Promi } } -async function handleNetworkCaptureRead(cmd: Command, workspace: string): Promise { +async function handleNetworkCaptureRead(cmd: Command, leaseKey: string): Promise { const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, workspace); + const tabId = await resolveTabId(cmdTabId, leaseKey); try { const data = await executor.readNetworkCapture(tabId); return pageScopedResult(cmd.id, tabId, data); @@ -1544,23 +1538,23 @@ async function handleWaitDownload(cmd: Command): Promise { } } -async function releaseWorkspaceLease(workspace: string, reason: string = 'released'): Promise { - const session = automationSessions.get(workspace); +async function releaseLease(leaseKey: string, reason: string = 'released'): Promise { + const session = automationSessions.get(leaseKey); if (!session) { - workspaceTimeoutOverrides.delete(workspace); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); + sessionTimeoutOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); return; } if (session.idleTimer) clearTimeout(session.idleTimer); - scheduleIdleAlarm(workspace, IDLE_TIMEOUT_NONE); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); if (session.owned) { const tabId = session.preferredTabId; if (tabId !== null) { - const hasOtherOwnedLease = [...automationSessions.entries()].some(([otherWorkspace, otherSession]) => - otherWorkspace !== workspace && + const hasOtherOwnedLease = [...automationSessions.entries()].some(([otherLease, otherSession]) => + otherLease !== leaseKey && otherSession.owned && otherSession.windowId === session.windowId && otherSession.preferredTabId !== null, @@ -1569,28 +1563,28 @@ async function releaseWorkspaceLease(workspace: string, reason: string = 'releas identity.evictTab(tabId); if (hasOtherOwnedLease) { await chrome.tabs.remove(tabId).catch(() => {}); - console.log(`[opencli] Released owned tab lease ${tabId} (${workspace}, ${reason})`); + console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); } else { try { const tab = await chrome.tabs.update(tabId, { url: BLANK_PAGE, active: true }); - await ensureOwnedContainerTabGroup(getOwnedWindowRole(workspace), session.windowId, [tab.id ?? tabId]); - console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (${workspace}, ${reason})`); + await ensureOwnedContainerTabGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]); + console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`); } catch { await chrome.tabs.remove(tabId).catch(() => {}); - console.log(`[opencli] Released owned tab lease ${tabId} (${workspace}, ${reason})`); + console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); } } } else { - console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (${workspace}, ${reason})`); + console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (session=${session.session}, surface=${session.surface}, ${reason})`); } } else if (session.preferredTabId !== null) { await safeDetach(session.preferredTabId); - console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (${workspace}, ${reason})`); + console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (session=${session.session}, surface=${session.surface}, ${reason})`); } - automationSessions.delete(workspace); - workspaceTimeoutOverrides.delete(workspace); - workspaceWindowModeOverrides.delete(workspace); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); await persistRuntimeState(); } @@ -1612,34 +1606,37 @@ async function reconcileTargetLeaseRegistry(): Promise { } automationSessions.clear(); - for (const [workspace, stored] of Object.entries(registry.leases)) { + for (const [leaseKey, stored] of Object.entries(registry.leases)) { const tabId = stored.preferredTabId; if (tabId === null) continue; try { const tab = await chrome.tabs.get(tabId); if (!isDebuggableUrl(tab.url)) continue; - const session = makeSession(workspace, { + const session = makeSession(leaseKey, { + session: typeof stored.session === 'string' ? stored.session : getSessionFromKey(leaseKey), + surface: stored.surface === 'adapter' ? 'adapter' : getSurfaceFromKey(leaseKey), + kind: stored.kind === 'bound' || stored.owned === false ? 'bound' : 'owned', windowId: tab.windowId, owned: stored.owned, preferredTabId: tabId, }); - const timeout = getIdleTimeout(workspace); - automationSessions.set(workspace, { + const timeout = getIdleTimeout(leaseKey); + automationSessions.set(leaseKey, { ...session, idleTimer: null, idleDeadlineAt: stored.idleDeadlineAt, }); if (session.owned) { - const role = getOwnedWindowRole(workspace); + const role = getOwnedWindowRole(leaseKey); if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId; await ensureOwnedContainerTabGroup(role, tab.windowId, [tabId]); } const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout; if (timeout > 0) { if (remaining <= 0) { - await releaseWorkspaceLease(workspace, 'reconciled idle expiry'); + await releaseLease(leaseKey, 'reconciled idle expiry'); } else { - resetWindowIdleTimer(workspace); + resetWindowIdleTimer(leaseKey); } } } catch { @@ -1653,10 +1650,12 @@ async function reconcileTargetLeaseRegistry(): Promise { async function handleSessions(cmd: Command): Promise { const now = Date.now(); - const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ - workspace, + const data = await Promise.all([...automationSessions.entries()].map(async ([leaseKey, session]) => ({ + session: session.session, windowId: session.windowId, owned: session.owned, + kind: session.kind, + surface: session.surface, preferredTabId: session.preferredTabId, contextId: session.contextId, ownership: session.ownership, @@ -1670,57 +1669,44 @@ async function handleSessions(cmd: Command): Promise { return { id: cmd.id, ok: true, data }; } -async function handleBind(cmd: Command, workspace: string): Promise { - if (!workspace.startsWith('bound:')) { - return { - id: cmd.id, - ok: false, - errorCode: 'invalid_bind_workspace', - error: `bind workspace must start with "bound:", got "${workspace}".`, - errorHint: 'Use the default "bound:default" or pass --workspace bound:.', - }; - } - const existing = automationSessions.get(workspace); +async function handleBind(cmd: Command, leaseKey: string): Promise { + const existing = automationSessions.get(leaseKey); if (existing?.owned) { - return { - id: cmd.id, - ok: false, - errorCode: 'invalid_bind_workspace', - error: `Workspace "${workspace}" already owns an automation tab lease and cannot be rebound to a user tab.`, - errorHint: 'Use a fresh bound: workspace, or close/unbind the existing session first.', - }; + await releaseLease(leaseKey, 'rebind'); } const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); - const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) - ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)); + const boundTab = activeTabs.find((tab) => isDebuggableUrl(tab.url)) + ?? fallbackTabs.find((tab) => isDebuggableUrl(tab.url)); if (!boundTab?.id) { return { id: cmd.id, ok: false, errorCode: 'bound_tab_not_found', - error: cmd.matchDomain || cmd.matchPathPrefix - ? `No visible tab in the current window matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}` - : 'No debuggable tab found in the current window', - errorHint: 'Focus the target Chrome tab/window or relax --domain / --path-prefix, then retry bind.', + error: 'No debuggable tab found in the current window', + errorHint: 'Focus the target Chrome tab/window, then retry bind.', }; } - if (existing && !existing.owned && existing.preferredTabId !== null && existing.preferredTabId !== boundTab.id) { - await executor.detach(existing.preferredTabId).catch(() => {}); + const current = automationSessions.get(leaseKey); + if (current && !current.owned && current.preferredTabId !== null && current.preferredTabId !== boundTab.id) { + await executor.detach(current.preferredTabId).catch(() => {}); } - setWorkspaceSession(workspace, { + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: 'bound', windowId: boundTab.windowId, owned: false, preferredTabId: boundTab.id, }); - resetWindowIdleTimer(workspace); - console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + resetWindowIdleTimer(leaseKey); + console.log(`[opencli] Session ${getSessionFromKey(leaseKey)} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); return pageScopedResult(cmd.id, boundTab.id, { url: boundTab.url, title: boundTab.title, - workspace, + session: getSessionFromKey(leaseKey), }); } @@ -1735,24 +1721,33 @@ export const __test__ = { resetWindowIdleTimer, handleCommand, getIdleTimeout, - workspaceTimeoutOverrides, + getLeaseKey, + sessionTimeoutOverrides, reconcileTargetLeaseRegistry, - getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null, - getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null, - setAutomationWindowId: (workspace: string, windowId: number | null) => { + getSession: (leaseKey: string = 'default') => automationSessions.get(leaseKey) ?? null, + getAutomationWindowId: (leaseKey: string = 'default') => automationSessions.get(leaseKey)?.windowId ?? null, + setAutomationWindowId: (leaseKey: string, windowId: number | null) => { if (windowId === null) { - const session = automationSessions.get(workspace); + const session = automationSessions.get(leaseKey); if (session?.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); + automationSessions.delete(leaseKey); return; } - setWorkspaceSession(workspace, { + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: 'owned', windowId, owned: true, preferredTabId: null, }); }, - setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => { - setWorkspaceSession(workspace, session); + setSession: (leaseKey: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => { + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: session.owned ? 'owned' : 'bound', + ...session, + }); }, }; diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index f97e54f37..db807e55e 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -31,8 +31,10 @@ export interface Command { page?: string; /** JS code to evaluate in page context (exec action) */ code?: string; - /** Logical workspace for automation session reuse */ - workspace?: string; + /** Browser session name for tab/page continuity. */ + session?: string; + /** Runtime surface selecting owned container policy. */ + surface?: 'browser' | 'adapter'; /** URL to navigate to (navigate action) */ url?: string; /** Sub-operation for tabs: list, new, close, select */ @@ -41,10 +43,6 @@ export interface Command { index?: number; /** Cookie domain filter */ domain?: string; - /** Optional hostname/domain to require for current-tab binding */ - matchDomain?: string; - /** Optional pathname prefix to require for current-tab binding */ - matchPathPrefix?: string; /** Screenshot format: png (default) or jpeg */ format?: 'png' | 'jpeg'; /** JPEG quality (0-100), only for jpeg format */ @@ -71,10 +69,8 @@ export interface Command { cdpParams?: Record; /** Window foreground/background policy for owned Browser Bridge containers. */ windowMode?: 'foreground' | 'background'; - /** Custom idle timeout in seconds for this workspace session. Overrides the default. */ + /** Custom idle timeout in seconds for this session. Overrides the default. */ idleTimeout?: number; - /** Explicitly allow navigation inside a borrowed bound tab. */ - allowBoundNavigation?: boolean; /** Frame index for cross-frame operations (0-based, from 'frames' action) */ frameIndex?: number; /** Browser profile/context selected by the CLI. Used by the daemon for routing. */ diff --git a/skills/opencli-adapter-author/references/api-discovery.md b/skills/opencli-adapter-author/references/api-discovery.md index 601d68d1b..e364efb02 100644 --- a/skills/opencli-adapter-author/references/api-discovery.md +++ b/skills/opencli-adapter-author/references/api-discovery.md @@ -91,7 +91,7 @@ opencli browser network --filter author,text,likes opencli browser network --detail ``` -capture 会持久化到 `~/.opencli/cache/browser-network/.json`(默认 TTL 24h),所以 `--detail` 即使跨多条其他命令也还在。 +capture 会持久化到 `~/.opencli/cache/browser-network/.json`(默认 TTL 24h),所以 `--detail` 即使跨多条其他命令也还在。 ### 关键 request headers diff --git a/skills/opencli-browser/SKILL.md b/skills/opencli-browser/SKILL.md index 55aa88bc5..3f25ecccd 100644 --- a/skills/opencli-browser/SKILL.md +++ b/skills/opencli-browser/SKILL.md @@ -22,35 +22,28 @@ Until `doctor` is green, nothing else will work. Typical failures: Chrome not ru --- -## Lease lifecycle +## Session lifecycle -- `opencli browser *` commands keep an owned tab lease alive between calls. Owned leases share a dedicated automation container and are released with `opencli browser close` or when the idle timeout expires. -- `opencli browser bind` binds a `bound:*` workspace to the Chrome tab you already have open. Use this for logged-in pages, SSO flows, or pages you manually positioned before handing control to the agent. -- `--window foreground|background` (or `OPENCLI_WINDOW=foreground|background`) chooses whether OpenCLI uses a foreground browser window or a background automation window. -- `--keep-tab true|false` (or `OPENCLI_KEEP_TAB=true|false`) chooses whether the tab lease is kept after the command. `opencli browser *` defaults to `true`; browser-backed adapter commands default to `false` unless the adapter opts into site reuse. +- `opencli browser *` commands require `--session `. Use the same session name for a multi-step flow; use a different name to isolate parallel browser work. +- Owned browser sessions keep a tab lease alive between calls. Release it with `opencli browser --session close` or let the idle timeout expire. +- `opencli browser bind --session ` binds the Chrome tab you already have open to that session. Use this for logged-in pages, SSO flows, or pages you manually positioned before handing control to the agent. +- `--window foreground|background` (or `OPENCLI_WINDOW=foreground|background`) chooses whether OpenCLI creates/focuses a foreground browser window or uses a background browser window for owned sessions. ### Bind Tab ```bash -opencli browser bind --domain example.com -opencli browser --workspace bound:default state -opencli browser --workspace bound:default click "Search" -opencli browser --workspace bound:default network -opencli browser unbind +opencli browser bind --session gmail +opencli browser --session gmail state +opencli browser --session gmail click "Search" +opencli browser --session gmail network +opencli browser unbind --session gmail ``` -Binding uses a separate `bound:*` workspace. It never owns the user window, never closes the user tab, and fails closed if the tab is closed or becomes non-debuggable. Re-run `bind` when you switch to a different real tab. +Binding never owns the user window and never closes the user tab. It fails closed if the tab is closed or becomes non-debuggable. Re-run `bind --session ` when you switch to a different real tab. -Use `--domain ` and `--path-prefix ` to avoid binding the wrong tab: +Navigation is allowed on bound sessions because the session now represents explicit agent ownership of that tab. Tab mutation (`tab new`, `tab select`, `tab close`) is still blocked for bound sessions. Use an owned session when you want OpenCLI to manage tab lifecycle. -```bash -opencli browser bind --workspace bound:gmail --domain mail.google.com --path-prefix /mail -opencli browser --workspace bound:gmail state -``` - -Navigation is blocked by default on bound workspaces because it can destroy the logged-in/positioned state you wanted to preserve. `browser open` and `browser back` require `--allow-navigate-bound`; tab mutation (`tab new`, `tab select`, `tab close`) is blocked for bound workspaces. Use a normal `browser:*` automation workspace when you want OpenCLI to own tab lifecycle. - -`opencli browser sessions` returns `idleMsRemaining: null` for bound workspaces. That means there is no OpenCLI idle-close timer; the binding lasts until `unbind`, tab close, window close, or daemon restart. +`opencli browser sessions` returns `idleMsRemaining: null` for bound sessions. That means there is no OpenCLI idle-close timer; the binding lasts until `unbind`, tab close, window close, or daemon restart. --- @@ -216,9 +209,9 @@ Default output keeps JSON/XML/plain-text and JS-like API responses, then drops o | `browser tab select [targetId]` | Make a tab the default. All subcommands accept `--tab ` to target one without changing the default. | | `browser tab close [targetId]` | Close by `page`. | | `browser back` | History back on the active tab. | -| `browser close` | Release the current automation tab lease when done. | -| `browser bind` | Bind `bound:default` (or `--workspace bound:`) to the current Chrome tab. | -| `browser unbind` | Detach a bound workspace without closing the user tab/window. | +| `browser close` | Release the current owned browser session when done. | +| `browser bind --session ` | Bind the current Chrome tab to a browser session. | +| `browser unbind --session ` | Detach a bound session without closing the user tab/window. | --- @@ -306,9 +299,9 @@ Rule of thumb: **one `state` per page transition, one `find` per follow-up query **Good — one shell, live session:** ```bash -opencli browser open "https://news.ycombinator.com" \ - && opencli browser state \ - && opencli browser click 3 +opencli browser --session hn open "https://news.ycombinator.com" \ + && opencli browser --session hn state \ + && opencli browser --session hn click 3 ``` **Bad — each line is a fresh shell, refs from call 1 are already forgotten when call 2 runs.** (Only a problem if you rely on shell-scoped state; browser refs themselves persist in-page, but interleaving unrelated shells invites races.) Prefer `&&` when the steps are meant to be atomic. @@ -322,24 +315,24 @@ opencli browser open "https://news.ycombinator.com" \ ### Fill a login form ```bash -opencli browser open "https://example.com/login" -opencli browser state # find [N] for email, password, submit -opencli browser type 4 "me@example.com" -opencli browser type 5 "hunter2" -opencli browser get value 4 # verify (autocomplete can eat chars) -opencli browser click 6 # submit -opencli browser wait selector "[data-testid=account-menu]" --timeout 15000 -opencli browser state # fresh refs on the logged-in page +opencli browser --session login open "https://example.com/login" +opencli browser --session login state # find [N] for email, password, submit +opencli browser --session login type 4 "me@example.com" +opencli browser --session login type 5 "hunter2" +opencli browser --session login get value 4 # verify (autocomplete can eat chars) +opencli browser --session login click 6 # submit +opencli browser --session login wait selector "[data-testid=account-menu]" --timeout 15000 +opencli browser --session login state # fresh refs on the logged-in page ``` ### Pick from a long dropdown ```bash -opencli browser state # sidebar shows [12] +opencli browser --session form find --css "select[name=country]" # the compound.options_total is 137, but compound.current is "" — unselected. -opencli browser select 12 "Uruguay" -opencli browser get value 12 # { value: "uy", match_level: "exact" } +opencli browser --session form select 12 "Uruguay" +opencli browser --session form get value 12 # { value: "uy", match_level: "exact" } ``` ### Pick from a custom React dropdown @@ -348,13 +341,13 @@ Use this for Radix, shadcn, Material UI, Mercury-style category fields, and other controls that are not native `