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
83 changes: 77 additions & 6 deletions apps/extension/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
attachRecordStepListener,
type RecordRuntimeDeps,
} from "@/tools/record";
import { chromeTabsApi } from "@/tools/shared";
import { chromeTabMutationApi } from "@/tools/tabs";
import { detectBrowserMeta } from "@/transport/handshake";
import type { Transport } from "@/transport/transport";
import { WSTransport } from "@/transport/ws-transport";
Expand Down Expand Up @@ -80,6 +82,29 @@ export default defineBackground(() => {
};
}

/**
* Authoritative overlay state for a *specific* tab. A user-created tab
* (`ctx.userTabs`) inside the Agent Window must never show the control
* mask — return hidden immediately. This is what lets a freshly-mounted
* content script receive the correct state on its first `overlay.ready`
* ping instead of flashing control and then hiding (design: decide before
* showing, never show then correct).
*/
function overlayStateForTab(tabId?: number, windowId?: number): OverlayAgentStateMessage {
if (typeof tabId === "number" && typeof windowId === "number") {
const ctx = sessions.findByWindowId(windowId);
if (ctx && ctx.userTabs.has(tabId)) {
return {
type: OVERLAY_AGENT_STATE,
sessionId: null,
mode: "hidden",
generation: overlayGeneration,
};
}
}
return overlayStateForWindow(windowId);
}

async function pushOverlayStateToTab(
tabId: number,
state: OverlayAgentStateMessage,
Expand All @@ -91,13 +116,27 @@ export default defineBackground(() => {
}
}

async function pushOverlayStateForTab(tabId: number, windowId?: number): Promise<void> {
const state = overlayStateForTab(tabId, windowId);
await pushOverlayStateToTab(tabId, state);
}

async function pushOverlayStateForWindow(windowId: number): Promise<void> {
const state = overlayStateForWindow(windowId);
const ctx = sessions.findByWindowId(windowId);
const baseState = overlayStateForWindow(windowId);
const tabs = await chrome.tabs.query({ windowId });
await Promise.all(
tabs.map((tab) =>
typeof tab.id === "number" ? pushOverlayStateToTab(tab.id, state) : Promise.resolve(),
),
tabs.map((tab) => {
if (typeof tab.id !== "number") return Promise.resolve();
// A user-created tab in the Agent Window must stay free for the user
// to operate — never show the control mask over it. Override the
// window-level control state with a hidden state for those tabs.
const isUserTab = ctx ? ctx.userTabs.has(tab.id) : false;
const state: OverlayAgentStateMessage = isUserTab
? { ...baseState, sessionId: null, mode: "hidden" }
: baseState;
return pushOverlayStateToTab(tab.id, state);
}),
);
}

Expand Down Expand Up @@ -136,13 +175,41 @@ export default defineBackground(() => {
if (typeof tab.windowId !== "number") return;
pushOverlayStateForAgentWindow(tab.windowId);
});
// A new tab inside an Agent Window is agent-owned (the home tab, matched by
// homeTabId, or a tool.tab_create flagged by the pending count) or
// user-created (via Chrome UI). Classify it so user-opened tabs are kept
// free and can be pushed a hidden overlay immediately. See
// SessionManager.classifyNewTab.
chrome.tabs.onCreated.addListener((tab) => {
if (typeof tab.windowId !== "number" || typeof tab.id !== "number") return;
const kind = sessions.classifyNewTab(tab.id, tab.windowId);
if (kind === "user") {
// User tab: explicitly free it from the agent control mask.
void pushOverlayStateToTab(tab.id, {
type: OVERLAY_AGENT_STATE,
sessionId: null,
mode: "hidden",
generation: overlayGeneration,
});
} else if (kind === "agent") {
// Agent tab (or home tab): make sure it reflects the session's
// current control mode.
void pushOverlayStateForAgentWindow(tab.windowId);
}
});
// Re-sync the storage.session flag on SW startup so a previous SW's
// stale `true` does not keep waking us on every page load until the
// first mutation (review M4/M5 round 3 m-R3-1).
void sessionsLive.refresh();
const cleanupAfterDisconnect = createDisconnectCleanup({
manager: sessions,
sessionStopDeps: { cdp },
// Same contract as the dispatcher's `tool.session_stop`: without these
// deps the agent-tab cleanup and the window-release path are dead code.
sessionStopDeps: {
cdp,
tabManagement: { tabs: chromeTabMutationApi },
tabsQuery: chromeTabsApi,
},
onSessionsChanged: () => {
void sessionsLive.syncFromManager();
},
Expand Down Expand Up @@ -309,7 +376,11 @@ export default defineBackground(() => {
}

if (msg.kind === OVERLAY_MSG_READY) {
sendResponse(overlayStateForWindow(sender.tab?.windowId));
// Decide per-tab *before* sending: a user-created tab gets hidden
// immediately so the content script never flashes the control mask.
if (typeof sender.tab?.id === "number") {
void pushOverlayStateForTab(sender.tab.id, sender.tab.windowId);
}
return false;
}

Expand Down
6 changes: 4 additions & 2 deletions apps/extension/src/lib/overlay-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
* between the content-script control overlay and the background SW.
*
* Content script → background:
* - `{ kind: "overlay.ready" }` → background replies with the authoritative
* overlay state for the sender's window.
* - `{ kind: "overlay.ready" }` → background decides the per-tab state first
* (see overlayStateForTab) and pushes the authoritative overlay state for
* that tab. It also proactively pushes on tab create and control-mode
* change, so a tab usually receives state before it ever sends ready.
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
* daemon (via a `session.user_interrupt` WS event) to cancel
* every inflight + queued tool call for that session with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe("disconnect session cleanup", () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => nextWindowId++),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
remove,
},
});
Expand Down Expand Up @@ -41,7 +41,7 @@ describe("disconnect session cleanup", () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
remove: vi.fn(() => removeGate),
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await manager.start("aa11");
Expand Down Expand Up @@ -73,7 +73,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const ctx = await manager.start("aa11");
Expand Down Expand Up @@ -113,7 +113,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const transport = fakeTransport();
Expand All @@ -129,7 +129,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const transport = fakeTransport();
Expand Down
49 changes: 48 additions & 1 deletion apps/extension/src/session-manager/__tests__/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function fakeAgentWindow(): AgentWindowApi & {
return id;
});
const removeMock = vi.fn(async (_id: number) => {});
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => {});
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => 0);
return {
create: createMock,
remove: removeMock,
Expand Down Expand Up @@ -151,6 +151,53 @@ describe("SessionManager", () => {
expect(sm.list()).toEqual([]);
});

describe("classifyNewTab (user vs agent tab freedom)", () => {
it("classifies the home tab as agent", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
// The home tab is agent-owned and matched by home tab id. The prior
// windowInitializing flag was cleared before the home tab's onCreated
// arrived, so matching it by boot timing was never reliable.
const homeTabId = ctx.homeTabId as number;
const kind = sm.classifyNewTab(homeTabId, ctx.agentWindowId);
expect(kind).toBe("agent");
expect(ctx.agentCreatedTabs.has(homeTabId)).toBe(true);
expect(ctx.userTabs.has(homeTabId)).toBe(false);
});

it("classifies a pending tab_create tab as agent", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
sm.markAgentTabPending(ctx.agentWindowId);
const kind = sm.classifyNewTab(11, ctx.agentWindowId);
expect(kind).toBe("agent");
expect(ctx.agentCreatedTabs.has(11)).toBe(true);
expect(ctx.userTabs.has(11)).toBe(false);
expect(ctx.pendingAgentTabCount).toBe(0);
});

it("classifies a user-opened tab (no pending) as user and keeps it free", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
const kind = sm.classifyNewTab(99, ctx.agentWindowId);
expect(kind).toBe("user");
expect(ctx.userTabs.has(99)).toBe(true);
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
});

it("matches multiple pending agent tabs to multiple onCreated events", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
sm.markAgentTabPending(ctx.agentWindowId);
sm.markAgentTabPending(ctx.agentWindowId);
expect(sm.classifyNewTab(11, ctx.agentWindowId)).toBe("agent");
// Second agent tab still pending → agent; not mistaken for user.
expect(sm.classifyNewTab(12, ctx.agentWindowId)).toBe("agent");
// No more pending → a later user tab is user.
expect(sm.classifyNewTab(99, ctx.agentWindowId)).toBe("user");
});
});

describe("findBorrowingSession", () => {
it("returns null when no session has borrowed the tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
Expand Down
17 changes: 12 additions & 5 deletions apps/extension/src/session-manager/agent-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ export interface AgentWindowApi {
* Guarantee the Agent Window has an active, CDP-navigable tab.
* `chrome://` pages (including the New Tab page) reject `Page.navigate`,
* so sessions bootstrap with `about:blank` instead.
*
* Resolves with the id of the activated (or newly created) tab, so callers
* can track the session's home tab without re-querying Chrome.
*/
ensureActiveTab(windowId: number, url: string): Promise<void>;
ensureActiveTab(windowId: number, url: string): Promise<number>;
}

/** Creation hints for a new Agent Window. */
Expand Down Expand Up @@ -49,15 +52,19 @@ export const chromeAgentWindowApi: AgentWindowApi = {
// cancellation success while the Agent Window remains open.
await chrome.windows.remove(windowId);
},
async ensureActiveTab(windowId: number, url: string): Promise<void> {
async ensureActiveTab(windowId: number, url: string): Promise<number> {
const tabs = await chrome.tabs.query({ windowId });
const first = tabs.find((t) => typeof t.id === "number");
if (first?.id) {
if (first?.id !== undefined) {
if (!first.active) {
await chrome.tabs.update(first.id, { active: true });
}
return;
return first.id;
}
await chrome.tabs.create({ windowId, url, active: true });
const created = await chrome.tabs.create({ windowId, url, active: true });
if (typeof created?.id !== "number") {
throw new Error("[bh] chrome.tabs.create returned no tab id");
}
return created.id;
},
};
Loading