From acffdadbd509ce7e22a3d808b46478df0fbe8e77 Mon Sep 17 00:00:00 2001 From: young001 Date: Tue, 28 Apr 2026 14:13:22 +0800 Subject: [PATCH] feat(extension): add --reuse-window flag for tab-mode automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open automation as a new tab in the user's last-focused Chrome window instead of spawning a separate window. Solves the "two windows" problem when the user already has a Chrome window dedicated to OpenCLI. Behaviour: - `--reuse-window` (or OPENCLI_REUSE_WINDOW=1) is intercepted in src/main.ts and propagated through the daemon → extension protocol. - The extension calls chrome.windows.getLastFocused({windowTypes:['normal']}) and chrome.tabs.create on that window, instead of chrome.windows.create. - Sessions track ownsWindow: when false (tab mode), idle timeout and close-window cleanup remove the tab via chrome.tabs.remove and never touch the host window. - Falls back to the legacy new-window path when no normal Chrome window is available, preserving zero-window behaviour. - Orthogonal to --focus (defaults to background tab) and --live (keeps the tab alive instead of the window). Safety: - resolveTabId fails closed in tab mode when the preferred tab is gone, refusing to silently take over an unrelated user tab in the host window. - chrome.tabs.onRemoved listener cleans up tab-mode sessions when the user manually closes our owned tab. Tests added in extension/src/background.test.ts: - Opens a new tab in the last-focused window when --reuse-window is set. - Falls back to chrome.windows.create when no normal window exists. - Idle timeout closes the tab (not the host window) for reuse-window sessions. --- CHANGELOG.md | 1 + README.md | 3 + README.zh-CN.md | 3 + extension/dist/background.js | 115 +++++++++++++++++++++++----- extension/src/background.test.ts | 89 ++++++++++++++++++++++ extension/src/background.ts | 127 ++++++++++++++++++++++++++----- extension/src/protocol.ts | 2 + src/browser/daemon-client.ts | 10 ++- src/main.ts | 5 ++ 9 files changed, 312 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d0c819b..a69e0aab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features * **browser** — `bind` attaches `bound:*` workspaces to user-owned Chrome tabs without taking over window lifecycle; `sessions` reports `idleMsRemaining: null` for bound workspaces because they do not schedule idle close timers. ([#1169](https://github.com/jackwener/opencli/issues/1169), [#929](https://github.com/jackwener/opencli/issues/929)) +* **extension** — `--reuse-window` (and `OPENCLI_REUSE_WINDOW=1`) opens automation as a new tab in the user's last-focused Chrome window instead of spawning a separate window. Orthogonal to `--focus` (default opens in background); `--live` keeps the tab open instead of the window. Falls back to a new window when no normal Chrome window is available. ## [1.7.8](https://github.com/jackwener/opencli/compare/v1.7.7...v1.7.8) (2026-04-25) diff --git a/README.md b/README.md index 9b79f106d..d3473aec1 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ OpenCLI is not only for websites. It can also: | `OPENCLI_DAEMON_PORT` | `19825` | HTTP port for the daemon-extension bridge | | `OPENCLI_WINDOW_FOCUSED` | `false` | Set to `1` to open automation windows in the foreground (useful for debugging). The `--focus` flag sets this. | | `OPENCLI_LIVE` | `false` | Set to `1` to keep the automation window open after an adapter command finishes (useful for inspection). The `--live` flag sets this. | +| `OPENCLI_REUSE_WINDOW` | `false` | Set to `1` to open automation as a new tab in your last-focused Chrome window instead of creating a separate window; falls back to a new window if no normal Chrome window exists. The `--reuse-window` 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 | | `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps | @@ -179,6 +180,8 @@ OpenCLI is not only for websites. It can also: `--focus` works for both `opencli browser *` and browser-backed adapter commands. `--live` is mainly for adapter commands: browser subcommands already keep the automation window open until you run `opencli browser close` or the idle timeout expires. +`--reuse-window` opens automation as a new tab inside your last-focused normal Chrome window instead of spawning a separate window. It is orthogonal to `--focus`: by default the new tab opens in the background (so your current tab is not disturbed); add `--focus` to switch to it. With `--reuse-window`, `--live` keeps the tab open instead of the whole window. If Chrome has zero normal windows when the command runs, it automatically falls back to creating a new window. + ## Update ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 8d1e279e9..c9a4ff9ec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -167,6 +167,7 @@ OpenCLI 不只是网站 CLI,还可以: | `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 | | `OPENCLI_WINDOW_FOCUSED` | `false` | 设为 `1` 时 automation 窗口在前台打开(适合调试)。`--focus` 标志会设置此变量 | | `OPENCLI_LIVE` | `false` | 设为 `1` 时 adapter 命令执行完后保留 automation 窗口不关闭(适合检查页面)。`--live` 标志会设置此变量 | +| `OPENCLI_REUSE_WINDOW` | `false` | 设为 `1` 时在你已有的 Chrome 窗口里以新 tab 方式打开 automation,而不是新开窗口;零窗口时自动 fallback 开新窗口。`--reuse-window` 标志会设置此变量 | | `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) | | `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) | | `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 | @@ -177,6 +178,8 @@ OpenCLI 不只是网站 CLI,还可以: `--focus` 同时适用于 `opencli browser *` 和浏览器型 adapter 命令。`--live` 主要是给 adapter 命令用的:`browser` 子命令本来就会一直保留 automation window,直到你手动执行 `opencli browser close` 或等空闲超时。 +`--reuse-window` 让 automation 在你最近聚焦过的那个 Chrome 普通窗口里新开一个 tab,而不是弹出独立窗口。它跟 `--focus` 正交:默认会以**后台 tab** 形式打开(不打扰你当前 tab),加 `--focus` 才会切到那个新 tab。`--live` 在 tab 模式下保留的是 tab 而不是窗口。如果当前 Chrome 没有任何普通窗口(极端情况),会自动 fallback 到新建窗口的旧行为。 + ## 更新 ```bash diff --git a/extension/dist/background.js b/extension/dist/background.js index 10fd5e6dc..ee8d452f9 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -521,6 +521,7 @@ function getIdleTimeout(workspace) { return IDLE_TIMEOUT_DEFAULT; } let windowFocused = false; +let reuseWindow = false; function getWorkspaceKey(workspace) { return workspace?.trim() || "default"; } @@ -545,8 +546,13 @@ function resetWindowIdleTimer(workspace) { return; } try { - await chrome.windows.remove(current.windowId); - console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1e3}s)`); + if (current.ownsWindow) { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1e3}s)`); + } else if (current.preferredTabId !== null) { + await chrome.tabs.remove(current.preferredTabId); + console.log(`[opencli] Automation tab ${current.preferredTabId} (${workspace}) closed (idle timeout, ${timeout / 1e3}s)`); + } } catch { } workspaceTimeoutOverrides.delete(workspace); @@ -578,6 +584,37 @@ async function getAutomationWindow(workspace, initialUrl) { } } const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + if (reuseWindow) { + let hostWindow = null; + try { + hostWindow = await chrome.windows.getLastFocused({ windowTypes: ["normal"] }); + } catch { + hostWindow = null; + } + if (hostWindow?.id != null) { + const tab = await chrome.tabs.create({ + windowId: hostWindow.id, + url: startUrl, + active: windowFocused + // --focus switches to it; otherwise opens in background + }); + const session2 = { + windowId: hostWindow.id, + idleTimer: null, + idleDeadlineAt: Date.now() + getIdleTimeout(workspace), + owned: true, + ownsWindow: false, + // we only own the tab, not the user's window + preferredTabId: tab.id + }; + automationSessions.set(workspace, session2); + console.log(`[opencli] Created automation tab ${tab.id} in window ${hostWindow.id} (${workspace}, start=${startUrl}, reuse-window)`); + resetWindowIdleTimer(workspace); + await waitForTabComplete(tab.id); + return session2.windowId; + } + console.log("[opencli] reuse-window requested but no normal Chrome window found, falling back to new window"); + } const win = await chrome.windows.create({ url: startUrl, focused: windowFocused, @@ -590,31 +627,38 @@ async function getAutomationWindow(workspace, initialUrl) { idleTimer: null, idleDeadlineAt: Date.now() + getIdleTimeout(workspace), owned: true, + ownsWindow: true, preferredTabId: null }; automationSessions.set(workspace, session); console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); resetWindowIdleTimer(workspace); const tabs = await chrome.tabs.query({ windowId: win.id }); - if (tabs[0]?.id) { - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 500); - const listener = (tabId, info) => { - if (tabId === tabs[0].id && info.status === "complete") { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timeout); - resolve(); - } - }; - if (tabs[0].status === "complete") { + if (tabs[0]?.id) await waitForTabComplete(tabs[0].id); + return session.windowId; +} +async function waitForTabComplete(tabId) { + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); + const listener = (changedTabId, info) => { + if (changedTabId === tabId && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + } + }; + chrome.tabs.get(tabId).then((tab) => { + if (tab.status === "complete") { clearTimeout(timeout); resolve(); } else { chrome.tabs.onUpdated.addListener(listener); } + }).catch(() => { + clearTimeout(timeout); + resolve(); }); - } - return session.windowId; + }); } chrome.windows.onRemoved.addListener(async (windowId) => { for (const [workspace, session] of automationSessions.entries()) { @@ -629,11 +673,19 @@ chrome.windows.onRemoved.addListener(async (windowId) => { chrome.tabs.onRemoved.addListener((tabId) => { evictTab(tabId); for (const [workspace, session] of automationSessions.entries()) { - if (!session.owned && session.preferredTabId === tabId) { + if (session.preferredTabId !== tabId) continue; + if (!session.owned) { if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(workspace); workspaceTimeoutOverrides.delete(workspace); console.log(`[opencli] Borrowed workspace ${workspace} detached from tab ${tabId} (tab closed)`); + continue; + } + if (!session.ownsWindow) { + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + workspaceTimeoutOverrides.delete(workspace); + console.log(`[opencli] Tab-mode workspace ${workspace} cleaned (tab ${tabId} closed)`); } } }); @@ -668,6 +720,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { async function handleCommand(cmd) { const workspace = getWorkspaceKey(cmd.workspace); windowFocused = cmd.windowFocused === true; + reuseWindow = cmd.reuseWindow === true; if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1e3); } @@ -875,6 +928,21 @@ async function resolveTab(tabId, workspace, initialUrl) { } } const windowId = await getAutomationWindow(workspace, initialUrl); + const sessAfterAcquire = automationSessions.get(workspace); + if (sessAfterAcquire && sessAfterAcquire.owned && !sessAfterAcquire.ownsWindow) { + if (sessAfterAcquire.preferredTabId !== null) { + try { + const tab = await chrome.tabs.get(sessAfterAcquire.preferredTabId); + if (isDebuggableUrl(tab.url)) return { tabId: tab.id, tab }; + } catch { + } + } + throw new CommandFailure( + "automation_tab_gone", + `Automation tab for workspace "${workspace}" was closed.`, + "Re-run the command; OpenCLI will open a fresh tab in the host window." + ); + } 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 }; @@ -1201,7 +1269,11 @@ async function handleCloseWindow(cmd, workspace) { if (session) { if (session.owned) { try { - await chrome.windows.remove(session.windowId); + if (session.ownsWindow) { + await chrome.windows.remove(session.windowId); + } else if (session.preferredTabId !== null) { + await chrome.tabs.remove(session.preferredTabId); + } } catch { } } else if (session.preferredTabId !== null) { @@ -1294,15 +1366,14 @@ async function handleBind(cmd, workspace) { } const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); - const allTabs = await chrome.tabs.query({}); - const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); + const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)); if (!boundTab?.id) { return { id: cmd.id, ok: false, errorCode: "bound_tab_not_found", - error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found", - errorHint: "Focus the target Chrome tab or relax --domain / --path-prefix, then retry bind." + 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." }; } if (existing && !existing.owned && existing.preferredTabId !== null && existing.preferredTabId !== boundTab.id) { @@ -1312,6 +1383,8 @@ async function handleBind(cmd, workspace) { setWorkspaceSession(workspace, { windowId: boundTab.windowId, owned: false, + ownsWindow: false, + // bound sessions never own a window — they borrow a user tab preferredTabId: boundTab.id }); resetWindowIdleTimer(workspace); diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index fca2413e7..8bcc1212f 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -106,6 +106,9 @@ function createChromeMock() { get: vi.fn(async (windowId: number) => ({ id: windowId })), create: vi.fn(async ({ url, focused, width, height, type }: any) => ({ id: 1, url, focused, width, height, type })), remove: vi.fn(async (_windowId: number) => {}), + getLastFocused: vi.fn(async (_filters?: { windowTypes?: string[] }) => ({ + id: 1, type: 'normal', focused: true, incognito: false, alwaysOnTop: false, state: 'normal', tabs: [], + })) as any, onRemoved: { addListener: vi.fn() } as Listener<(windowId: number) => void>, }, alarms: { @@ -546,6 +549,92 @@ describe('background tab isolation', () => { expect(mod.__test__.getSession('site:notebooklm')).toBeNull(); }); + it('reuse-window opens a new tab in the last-focused Chrome window', async () => { + const { chrome, create } = createChromeMock(); + chrome.windows.getLastFocused = vi.fn(async () => ({ + id: 99, type: 'normal', focused: true, incognito: false, alwaysOnTop: false, state: 'normal', tabs: [], + })) as any; + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + + await mod.__test__.handleCommand({ + id: 'reuse-1', + action: 'navigate', + url: 'https://example.com/', + workspace: 'site:reuse-test', + reuseWindow: true, + }); + + // No new window should have been created + expect(chrome.windows.create).not.toHaveBeenCalled(); + // A tab should have been created in the last-focused window (id=99) + expect(create).toHaveBeenCalledWith(expect.objectContaining({ windowId: 99 })); + + const session = mod.__test__.getSession('site:reuse-test'); + expect(session).toEqual(expect.objectContaining({ + windowId: 99, + owned: true, + ownsWindow: false, + })); + expect(session?.preferredTabId).toBeTypeOf('number'); + }); + + it('reuse-window falls back to a new window when no normal Chrome window exists', async () => { + const { chrome } = createChromeMock(); + chrome.windows.getLastFocused = vi.fn(async () => { + throw new Error('no normal window available'); + }) as any; + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + + await mod.__test__.handleCommand({ + id: 'reuse-fallback', + action: 'navigate', + url: 'https://example.com/', + workspace: 'site:reuse-fallback', + reuseWindow: true, + }); + + // Falls back to legacy behaviour: a brand-new automation window + expect(chrome.windows.create).toHaveBeenCalled(); + + const session = mod.__test__.getSession('site:reuse-fallback'); + expect(session).toEqual(expect.objectContaining({ + owned: true, + ownsWindow: true, + })); + }); + + it('idle timeout closes the tab (not the window) for reuse-window sessions', async () => { + const { chrome } = createChromeMock(); + chrome.windows.getLastFocused = vi.fn(async () => ({ + id: 99, type: 'normal', focused: true, incognito: false, alwaysOnTop: false, state: 'normal', tabs: [], + })) as any; + vi.useFakeTimers(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + + await mod.__test__.handleCommand({ + id: 'reuse-idle', + action: 'navigate', + url: 'https://example.com/', + workspace: 'site:reuse-idle', + reuseWindow: true, + }); + + // 30s adapter idle timeout fires + await vi.advanceTimersByTimeAsync(30001); + + // Cleanup must close the tab — never the host window — to avoid killing + // the user's own Chrome window in tab-mode. + expect(chrome.tabs.remove).toHaveBeenCalled(); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + expect(mod.__test__.getSession('site:reuse-idle')).toBeNull(); + }); + it('uses 10-minute timeout for browser:* workspaces', async () => { const { chrome } = createChromeMock(); vi.useFakeTimers(); diff --git a/extension/src/background.ts b/extension/src/background.ts index c8e41aff8..e3c943242 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -127,6 +127,8 @@ type AutomationSession = { idleTimer: ReturnType | null; idleDeadlineAt: number; owned: boolean; + /** When false, OpenCLI owns only `preferredTabId` inside a user-shared window; cleanup must close the tab, not the window. */ + ownsWindow: boolean; preferredTabId: number | null; }; @@ -156,6 +158,7 @@ function getIdleTimeout(workspace: string): number { } let windowFocused = false; // set per-command from daemon's OPENCLI_WINDOW_FOCUSED +let reuseWindow = false; // set per-command from daemon's OPENCLI_REUSE_WINDOW (--reuse-window flag) function getWorkspaceKey(workspace?: string): string { return workspace?.trim() || 'default'; @@ -182,8 +185,13 @@ function resetWindowIdleTimer(workspace: string): void { return; } try { - await chrome.windows.remove(current.windowId); - console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1000}s)`); + if (current.ownsWindow) { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1000}s)`); + } else if (current.preferredTabId !== null) { + await chrome.tabs.remove(current.preferredTabId); + console.log(`[opencli] Automation tab ${current.preferredTabId} (${workspace}) closed (idle timeout, ${timeout / 1000}s)`); + } } catch { // Already gone } @@ -226,6 +234,40 @@ async function getAutomationWindow(workspace: string, initialUrl?: string): Prom // Use the target URL directly if it's a safe navigation URL, otherwise fall back to about:blank. const startUrl = (initialUrl && isSafeNavigationUrl(initialUrl)) ? initialUrl : BLANK_PAGE; + // ── Tab mode (--reuse-window): open a new tab in the user's last-focused + // Chrome window instead of creating a brand-new window. Falls back to + // window mode below when no normal Chrome window exists. + if (reuseWindow) { + let hostWindow: chrome.windows.Window | null = null; + try { + hostWindow = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }); + } catch { + hostWindow = null; + } + if (hostWindow?.id != null) { + const tab = await chrome.tabs.create({ + windowId: hostWindow.id, + url: startUrl, + active: windowFocused, // --focus switches to it; otherwise opens in background + }); + const session: AutomationSession = { + windowId: hostWindow.id, + idleTimer: null, + idleDeadlineAt: Date.now() + getIdleTimeout(workspace), + owned: true, + ownsWindow: false, // we only own the tab, not the user's window + preferredTabId: tab.id!, + }; + automationSessions.set(workspace, session); + console.log(`[opencli] Created automation tab ${tab.id} in window ${hostWindow.id} (${workspace}, start=${startUrl}, reuse-window)`); + resetWindowIdleTimer(workspace); + await waitForTabComplete(tab.id!); + return session.windowId; + } + console.log('[opencli] reuse-window requested but no normal Chrome window found, falling back to new window'); + } + + // ── Window mode (default): dedicated automation window ──────────────── // Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid // state value for windows.create(). The window defaults to 'normal' state anyway. const win = await chrome.windows.create({ @@ -240,6 +282,7 @@ async function getAutomationWindow(workspace: string, initialUrl?: string): Prom idleTimer: null, idleDeadlineAt: Date.now() + getIdleTimeout(workspace), owned: true, + ownsWindow: true, preferredTabId: null, }; automationSessions.set(workspace, session); @@ -247,26 +290,33 @@ async function getAutomationWindow(workspace: string, initialUrl?: string): Prom resetWindowIdleTimer(workspace); // Wait for the initial tab to finish loading instead of a fixed 200ms sleep. const tabs = await chrome.tabs.query({ windowId: win.id! }); - if (tabs[0]?.id) { - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 500); // fallback cap - const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { - if (tabId === tabs[0].id && info.status === 'complete') { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timeout); - resolve(); - } - }; - // Check if already complete before listening - if (tabs[0].status === 'complete') { + if (tabs[0]?.id) await waitForTabComplete(tabs[0].id); + return session.windowId; +} + +/** Wait for a tab to reach status='complete', with a 500ms fallback cap. */ +async function waitForTabComplete(tabId: number): Promise { + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); + const listener = (changedTabId: number, info: chrome.tabs.TabChangeInfo) => { + if (changedTabId === tabId && info.status === 'complete') { + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + } + }; + chrome.tabs.get(tabId).then((tab) => { + if (tab.status === 'complete') { clearTimeout(timeout); resolve(); } else { chrome.tabs.onUpdated.addListener(listener); } + }).catch(() => { + clearTimeout(timeout); + resolve(); }); - } - return session.windowId; + }); } // Clean up when the automation window is closed @@ -285,11 +335,21 @@ chrome.windows.onRemoved.addListener(async (windowId) => { chrome.tabs.onRemoved.addListener((tabId) => { identity.evictTab(tabId); for (const [workspace, session] of automationSessions.entries()) { - if (!session.owned && session.preferredTabId === tabId) { + if (session.preferredTabId !== tabId) continue; + // Bound-current (borrowed) sessions: user closed the tab we were attached to. + if (!session.owned) { if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(workspace); workspaceTimeoutOverrides.delete(workspace); console.log(`[opencli] Borrowed workspace ${workspace} detached from tab ${tabId} (tab closed)`); + continue; + } + // Tab-mode automation sessions (--reuse-window): our owned tab was closed. + if (!session.ownsWindow) { + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + workspaceTimeoutOverrides.delete(workspace); + console.log(`[opencli] Tab-mode workspace ${workspace} cleaned (tab ${tabId} closed)`); } } }); @@ -337,6 +397,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { async function handleCommand(cmd: Command): Promise { const workspace = getWorkspaceKey(cmd.workspace); windowFocused = cmd.windowFocused === true; + reuseWindow = cmd.reuseWindow === true; // Apply custom idle timeout if specified in the command if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1000); @@ -592,6 +653,24 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU // Get (or create) the automation window const windowId = await getAutomationWindow(workspace, initialUrl); + // Tab-mode safety: in --reuse-window the host window also contains the user's + // own tabs. If our preferred tab was lost above, do NOT silently take over + // an unrelated user tab — fail closed and let the caller re-issue. + const sessAfterAcquire = automationSessions.get(workspace); + if (sessAfterAcquire && sessAfterAcquire.owned && !sessAfterAcquire.ownsWindow) { + if (sessAfterAcquire.preferredTabId !== null) { + try { + const tab = await chrome.tabs.get(sessAfterAcquire.preferredTabId); + if (isDebuggableUrl(tab.url)) return { tabId: tab.id!, tab }; + } catch { /* fall through to error */ } + } + throw new CommandFailure( + 'automation_tab_gone', + `Automation tab for workspace "${workspace}" was closed.`, + 'Re-run the command; OpenCLI will open a fresh tab in the host window.', + ); + } + // Prefer an existing debuggable tab const tabs = await chrome.tabs.query({ windowId }); const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url)); @@ -967,9 +1046,13 @@ async function handleCloseWindow(cmd: Command, workspace: string): Promise {}); @@ -1089,6 +1172,7 @@ async function handleBind(cmd: Command, workspace: string): Promise { setWorkspaceSession(workspace, { windowId: boundTab.windowId, owned: false, + ownsWindow: false, // bound sessions never own a window — they borrow a user tab preferredTabId: boundTab.id, }); resetWindowIdleTimer(workspace); @@ -1124,10 +1208,11 @@ export const __test__ = { setWorkspaceSession(workspace, { windowId, owned: true, + ownsWindow: true, preferredTabId: null, }); }, - setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => { - setWorkspaceSession(workspace, session); + setSession: (workspace: string, session: { windowId: number; owned: boolean; ownsWindow?: boolean; preferredTabId: number | null }) => { + setWorkspaceSession(workspace, { ownsWindow: session.owned, ...session }); }, }; diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 3d8767a16..6d0581467 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -64,6 +64,8 @@ export interface Command { cdpParams?: Record; /** When true, automation windows are created in the foreground (focused) */ windowFocused?: boolean; + /** When true, automation opens a new tab in the user's last-focused Chrome window instead of creating a new window. */ + reuseWindow?: boolean; /** Custom idle timeout in seconds for this workspace session. Overrides the default. */ idleTimeout?: number; /** Explicitly allow navigation inside a borrowed bound-current tab. */ diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 4bed20c29..e3be4548b 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -48,6 +48,8 @@ export interface DaemonCommand { cdpParams?: Record; /** When true, automation windows are created in the foreground */ windowFocused?: boolean; + /** When true, automation reuses the user's last-focused Chrome window by opening a new tab instead of creating a new window. */ + reuseWindow?: boolean; /** Custom idle timeout in seconds for this workspace session. Overrides the default. */ idleTimeout?: number; /** Explicitly allow navigation inside a borrowed bound-current tab. */ @@ -156,7 +158,13 @@ async function sendCommandRaw( const id = generateId(); const wf = process.env.OPENCLI_WINDOW_FOCUSED; const windowFocused = (wf === '1' || wf === 'true') ? true : undefined; - const command: DaemonCommand = { id, action, ...params, ...(windowFocused && { windowFocused }) }; + const rw = process.env.OPENCLI_REUSE_WINDOW; + const reuseWindow = (rw === '1' || rw === 'true') ? true : undefined; + const command: DaemonCommand = { + id, action, ...params, + ...(windowFocused && { windowFocused }), + ...(reuseWindow && { reuseWindow }), + }; try { const res = await requestDaemon('/command', { method: 'POST', diff --git a/src/main.ts b/src/main.ts index d7910faf8..c1a14292c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,6 +44,11 @@ const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis'); process.env.OPENCLI_WINDOW_FOCUSED = '1'; process.argv.splice(focusIdx, 1); } + const reuseIdx = process.argv.indexOf('--reuse-window'); + if (reuseIdx !== -1) { + process.env.OPENCLI_REUSE_WINDOW = '1'; + process.argv.splice(reuseIdx, 1); + } } // ── Ultra-fast path: lightweight commands bypass full discovery ──────────