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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 应用 |
Expand All @@ -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
Expand Down
115 changes: 94 additions & 21 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ function getIdleTimeout(workspace) {
return IDLE_TIMEOUT_DEFAULT;
}
let windowFocused = false;
let reuseWindow = false;
function getWorkspaceKey(workspace) {
return workspace?.trim() || "default";
}
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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()) {
Expand All @@ -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)`);
}
}
});
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
89 changes: 89 additions & 0 deletions extension/src/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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();
Expand Down
Loading