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
72 changes: 67 additions & 5 deletions apps/extension/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,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 @@ -88,13 +111,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 @@ -133,6 +170,27 @@ export default defineBackground(() => {
if (typeof tab.windowId !== "number") return;
pushOverlayStateForAgentWindow(tab.windowId);
});
// A new tab inside an Agent Window is either agent-created (via
// 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" || kind === "initializing") {
// 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).
Expand Down Expand Up @@ -284,7 +342,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
51 changes: 50 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 @@ -87,6 +87,55 @@ describe("SessionManager", () => {
expect(sm.list()).toEqual([]);
});

describe("classifyNewTab (user vs agent tab freedom)", () => {
it("classifies a tab born during window init as agent (home tab)", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
// Simulate the onCreated event for the home tab while still initializing.
// (start() already clears the flag, so re-add to emulate the race.)
(sm as unknown as { windowInitializing: Set<number> }).windowInitializing.add(
ctx.agentWindowId,
);
const kind = sm.classifyNewTab(0, ctx.agentWindowId);
expect(kind).toBe("initializing");
expect(ctx.agentCreatedTabs.has(0)).toBe(true);
expect(ctx.userTabs.has(0)).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
83 changes: 82 additions & 1 deletion apps/extension/src/session-manager/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ export interface SessionContext {
agentWindowId: number;
refStore: RefStore;
borrowedTabs: Map<number, BorrowedTab>;
/**
* Tabs created by the agent via `tool.tab_create` in this session's
* Agent Window. Tracked so `session_stop` can close them before
* releasing the window (design §3.1). User-created tabs (via Chrome UI)
* never enter this set.
*/
agentCreatedTabs: Set<number>;
/**
* Tabs the user opened themselves inside the Agent Window via Chrome UI
* (new-tab button, Cmd+T, right-click → open in new tab, …). These must
* NOT be controlled by the agent overlay and must be left free for the
* user to operate. Distinguishing them from agent-created tabs is done
* by the `chrome.tabs.onCreated` listener in background.ts, which consults
* `pendingAgentTabCount` to tell "opened via tool.tab_create" apart from
* "opened by the user".
*/
userTabs: Set<number>;
/**
* Number of agent tabs currently being created via `tool.tab_create` but
* whose `onCreated` event has not yet been observed. `handleTabCreate`
* increments this *before* calling `chrome.tabs.create`; the
* `onCreated` listener decrements it once per new tab in the Agent Window.
* This lets us tell agent-created tabs from user-created tabs even though
* both fire `onCreated` (design §3.1, user-tab freedom fix).
*/
pendingAgentTabCount: number;
/**
* Id of the home tab created/activated when the session started
* (`ensureActiveTab`). Used to clean up the home tab precisely on
* stop instead of matching by URL (design §3.2).
*/
homeTabId: number | null;
createdAtMs: number;
}

Expand Down Expand Up @@ -41,6 +73,13 @@ export class SessionManager {
private readonly sessions = new Map<string, SessionContext>();
private readonly windowIndex = new Map<number, string>();
private readonly borrowReservations = new Map<number, string>();
/**
* Windows that are still being initialised by `start()` (the home tab is
* being created). Tabs born into one of these windows via `onCreated`
* belong to the agent (the home tab), not the user, and must not be
* mistaken for user-created tabs.
*/
private readonly windowInitializing = new Set<number>();
private readonly agentWindow: AgentWindowApi;
private readonly now: () => number;

Expand Down Expand Up @@ -133,19 +172,61 @@ export class SessionManager {
throw new Error(`[bh] session ${sessionId} already exists`);
}
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
// Mark the window as initialising so `onCreated` (driven by the home
// tab) does not mistake the home tab for a user-created tab.
this.windowInitializing.add(windowId);
const homeTabId = await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
const ctx: SessionContext = {
sessionId,
agentWindowId: windowId,
refStore: new RefStore(),
borrowedTabs: new Map(),
agentCreatedTabs: new Set(),
userTabs: new Set(),
pendingAgentTabCount: 0,
homeTabId,
createdAtMs: this.now(),
};
this.sessions.set(sessionId, ctx);
this.windowIndex.set(windowId, sessionId);
this.windowInitializing.delete(windowId);
return ctx;
}

/**
* Record that a `tool.tab_create` is about to open a tab. Called *before*
* `chrome.tabs.create` so the pending count is visible to the
* `onCreated` listener that will fire for the new tab.
*/
markAgentTabPending(windowId: number): void {
const ctx = this.findByWindowId(windowId);
if (!ctx) return;
ctx.pendingAgentTabCount += 1;
}

/**
* Called by the `onCreated` listener for each new tab in an Agent Window.
* Returns `"agent"` when the tab corresponds to a pending `tool.tab_create`
* (and registers it), `"user"` when the user opened it via Chrome UI, or
* `"initializing"` when the window is still booting its home tab.
*/
classifyNewTab(tabId: number, windowId: number): "agent" | "user" | "initializing" | "unknown" {
const ctx = this.findByWindowId(windowId);
if (!ctx) return "unknown";
if (this.windowInitializing.has(windowId)) {
// Home tab / window boot — belongs to the agent, not the user.
ctx.agentCreatedTabs.add(tabId);
return "initializing";
}
if (ctx.pendingAgentTabCount > 0) {
ctx.pendingAgentTabCount -= 1;
ctx.agentCreatedTabs.add(tabId);
return "agent";
}
ctx.userTabs.add(tabId);
return "user";
}

/**
* Tear down a session: close its Agent Window and drop the context.
*
Expand Down
Loading