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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,13 @@ async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "back
container.windowId = win.id;
await persistRuntimeState();
console.log(`[opencli] Created owned ${role} window ${container.windowId} (start=${startUrl})`);
if (mode === "background") {
try {
await chrome.windows.update(container.windowId, { state: "minimized", focused: false });
} catch (e) {
console.warn("[opencli] minimize automation window failed:", e);
}
}
const tabs = await chrome.tabs.query({ windowId: win.id });
const initialTabId = tabs[0]?.id;
if (initialTabId) {
Expand Down Expand Up @@ -1317,7 +1324,7 @@ async function createOwnedTabLeaseUnlocked(leaseKey, initialUrl) {
tab = await chrome.tabs.get(initialTabId);
}
} else {
tab = await chrome.tabs.create({ windowId, url: targetUrl, active: true });
tab = await chrome.tabs.create({ windowId, url: targetUrl, active: getWindowMode(leaseKey) === "foreground" });
}
const tabId = tab.id;
if (!tabId) throw new Error("Failed to create tab lease in automation container");
Expand Down Expand Up @@ -1675,7 +1682,7 @@ async function resolveTab(tabId, leaseKey, initialUrl) {
} catch {
}
}
const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: true });
const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: getWindowMode(leaseKey) === "foreground" });
if (!newTab.id) throw new Error("Failed to create tab in automation container");
await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]);
return { tabId: newTab.id, tab: await chrome.tabs.get(newTab.id) };
Expand Down Expand Up @@ -1843,7 +1850,7 @@ async function handleTabs(cmd, leaseKey) {
return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url });
}
const windowId = await getAutomationWindow(leaseKey);
let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: getWindowMode(leaseKey) === "foreground" });
const tabId = tab.id;
if (!tabId) return { id: cmd.id, ok: false, error: "Failed to create tab" };
const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]);
Expand Down
8 changes: 6 additions & 2 deletions extension/src/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ function createChromeMock() {
windows: {
get: vi.fn(async (windowId: number) => ({ id: windowId, focused: windowId === lastFocusedWindowId })),
create: vi.fn(async ({ url, focused, width, height, type }: any) => ({ id: 1, url, focused, width, height, type })),
update: vi.fn(async (_windowId: number, _updates: any) => {}),
remove: vi.fn(async (_windowId: number) => {}),
onRemoved: { addListener: vi.fn() } as Listener<(windowId: number) => void>,
},
Expand Down Expand Up @@ -561,7 +562,9 @@ describe('background tab isolation', () => {
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 });
// adapter sessions run in background window mode, so new tabs must not be
// activated (activating them can pull the window to the foreground on macOS).
expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: false });
});

it('reuses the initial container tab for first tab-new lease instead of leaving a blank tab', async () => {
Expand Down Expand Up @@ -876,7 +879,8 @@ describe('background tab isolation', () => {
}));
expect(maxInFlight).toBe(2);
expect(chrome.windows.create).toHaveBeenCalledTimes(1);
expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'about:blank', active: true });
// adapter (background) sessions create inactive tabs to avoid focus steal
expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'about:blank', active: false });
});

it('releases owned sessions without closing the shared container', async () => {
Expand Down
18 changes: 15 additions & 3 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,18 @@ async function ensureOwnedContainerWindowUnlocked(
await persistRuntimeState();
console.log(`[opencli] Created owned ${role} window ${container.windowId} (start=${startUrl})`);

// macOS 15.x (and intermittently 26.x) ignore `focused: false` on windows.create()
// and pull the new window to the foreground anyway. See jackwener/opencli#739.
// Counteract by minimizing right after creation. `state: 'minimized'` is rejected
// when combined with width/height on create(), but is accepted on update().
if (mode === 'background') {
try {
await chrome.windows.update(container.windowId, { state: 'minimized', focused: false });
} catch (e) {
console.warn('[opencli] minimize automation window failed:', e);
}
}

// Wait for the initial tab to finish loading instead of a fixed 200ms sleep.
const tabs = await chrome.tabs.query({ windowId: win.id! });
const initialTabId = tabs[0]?.id;
Expand Down Expand Up @@ -941,7 +953,7 @@ async function createOwnedTabLeaseUnlocked(leaseKey: string, initialUrl?: string
tab = await chrome.tabs.get(initialTabId);
}
} else {
tab = await chrome.tabs.create({ windowId, url: targetUrl, active: true });
tab = await chrome.tabs.create({ windowId, url: targetUrl, active: getWindowMode(leaseKey) === 'foreground' });
}
const tabId = tab.id;
if (!tabId) throw new Error('Failed to create tab lease in automation container');
Expand Down Expand Up @@ -1394,7 +1406,7 @@ async function resolveTab(tabId: number | undefined, leaseKey: string, initialUr
}

// Fallback: create a new tab
const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: true });
const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: getWindowMode(leaseKey) === 'foreground' });
if (!newTab.id) throw new Error('Failed to create tab in automation container');
await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]);
return { tabId: newTab.id, tab: await chrome.tabs.get(newTab.id) };
Expand Down Expand Up @@ -1599,7 +1611,7 @@ async function handleTabs(cmd: Command, leaseKey: string): Promise<Result> {
return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url });
}
const windowId = await getAutomationWindow(leaseKey);
let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: getWindowMode(leaseKey) === 'foreground' });
const tabId = tab.id;
if (!tabId) return { id: cmd.id, ok: false, error: 'Failed to create tab' };
const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]);
Expand Down
11 changes: 10 additions & 1 deletion src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,16 @@ async function sendCommandRaw(
: undefined;
const contextId = params.contextId ?? resolveProfileContextId();
const windowMode = params.windowMode ?? envWindowMode;
const command: DaemonCommand = { id, action, ...params, ...(contextId && { contextId }), ...(windowMode && { windowMode }) };
// OPENCLI_IDLE_TIMEOUT_MS: keep automation window alive longer than the
// 30s adapter default so polling pipelines reuse the same window instead
// of forcing a new windows.create() — the real focus-steal trigger on
// macOS 15.x / 26.x. Caller-supplied params.idleTimeout (seconds) wins.
const rawIdleMs = process.env.OPENCLI_IDLE_TIMEOUT_MS;
const idleTimeoutFromEnv = rawIdleMs && /^\d+$/.test(rawIdleMs)
? Math.max(0, Math.floor(parseInt(rawIdleMs, 10) / 1000))
: undefined;
const idleTimeout = params.idleTimeout ?? idleTimeoutFromEnv;
const command: DaemonCommand = { id, action, ...params, ...(contextId && { contextId }), ...(windowMode && { windowMode }), ...(idleTimeout !== undefined && { idleTimeout }) };
try {
const res = await requestDaemon('/command', {
method: 'POST',
Expand Down
Loading