mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Initial public release
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AGENT_WINDOW_HOME, chromeAgentWindowApi } from "../agent-window";
|
||||
|
||||
describe("chromeAgentWindowApi.ensureActiveTab", () => {
|
||||
const query = vi.fn();
|
||||
const update = vi.fn();
|
||||
const create = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: { query, update, create },
|
||||
});
|
||||
query.mockReset();
|
||||
update.mockReset();
|
||||
create.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("activates an existing tab when the window already has one", async () => {
|
||||
query.mockResolvedValue([{ id: 7, active: false }]);
|
||||
update.mockResolvedValue({});
|
||||
|
||||
await chromeAgentWindowApi.ensureActiveTab(100, AGENT_WINDOW_HOME);
|
||||
|
||||
expect(query).toHaveBeenCalledWith({ windowId: 100 });
|
||||
expect(update).toHaveBeenCalledWith(7, { active: true });
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates about:blank when the Agent Window has no tabs", async () => {
|
||||
query.mockResolvedValue([]);
|
||||
create.mockResolvedValue({ id: 8 });
|
||||
|
||||
await chromeAgentWindowApi.ensureActiveTab(100, AGENT_WINDOW_HOME);
|
||||
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
windowId: 100,
|
||||
url: AGENT_WINDOW_HOME,
|
||||
active: true,
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport";
|
||||
import type { ConnectionState, ProtocolFrame } from "@/transport/types";
|
||||
import { attachSessionEventHandler, type WindowRemovedListener } from "../event-handler";
|
||||
import { SessionManager } from "../manager";
|
||||
|
||||
function fakeWindowEvents() {
|
||||
const listeners = new Set<(windowId: number) => void>();
|
||||
const api: WindowRemovedListener = {
|
||||
addListener: (cb) => listeners.add(cb),
|
||||
removeListener: (cb) => listeners.delete(cb),
|
||||
};
|
||||
return {
|
||||
api,
|
||||
emit(id: number) {
|
||||
for (const l of listeners) l(id);
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTransport(): Transport & { sent: ProtocolFrame[] } {
|
||||
const sent: ProtocolFrame[] = [];
|
||||
const t: Transport & { sent: ProtocolFrame[] } = {
|
||||
state: "connected" as ConnectionState,
|
||||
sent,
|
||||
connect: () => Promise.resolve(),
|
||||
disconnect: () => Promise.resolve(),
|
||||
send: (msg) => sent.push(msg),
|
||||
onMessage: (_h: FrameHandler) => ({ dispose: () => {} }),
|
||||
onConnectionStateChange: (_h: ConnectionStateHandler) => ({ dispose: () => {} }),
|
||||
};
|
||||
return t;
|
||||
}
|
||||
|
||||
describe("attachSessionEventHandler", () => {
|
||||
it("drops the local session and emits session.window_closed when the agent window closes", async () => {
|
||||
const manager = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 4242),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
await manager.start("aa11");
|
||||
const transport = fakeTransport();
|
||||
const events = fakeWindowEvents();
|
||||
const cdp = { detachSession: vi.fn(async () => {}) };
|
||||
|
||||
attachSessionEventHandler({
|
||||
manager,
|
||||
transport,
|
||||
windowEvents: events.api,
|
||||
cdp,
|
||||
});
|
||||
expect(events.listenerCount()).toBe(1);
|
||||
|
||||
events.emit(4242);
|
||||
await vi.waitUntil(() => transport.sent.length > 0);
|
||||
|
||||
expect(cdp.detachSession).toHaveBeenCalledWith("aa11");
|
||||
expect(manager.has("aa11")).toBe(false);
|
||||
expect(transport.sent).toEqual([
|
||||
{
|
||||
event: "session.window_closed",
|
||||
payload: { session_id: "aa11", reason: "user_closed_window" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports borrowed tabs as return failures when the Agent Window was already closed", async () => {
|
||||
const manager = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 4242),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
const ctx = await manager.start("aa11");
|
||||
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 0 });
|
||||
const transport = fakeTransport();
|
||||
const events = fakeWindowEvents();
|
||||
|
||||
attachSessionEventHandler({
|
||||
manager,
|
||||
transport,
|
||||
windowEvents: events.api,
|
||||
});
|
||||
|
||||
events.emit(4242);
|
||||
for (let i = 0; i < 4; i += 1) await Promise.resolve();
|
||||
|
||||
expect(transport.sent).toEqual([
|
||||
{
|
||||
event: "session.window_closed",
|
||||
payload: {
|
||||
session_id: "aa11",
|
||||
reason: "user_closed_window",
|
||||
return_failures: [
|
||||
{
|
||||
tab_id: 7,
|
||||
code: "cdp_failed",
|
||||
message: "Agent Window was closed before borrowed tab could be returned",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores non-agent windows", async () => {
|
||||
const manager = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 1),
|
||||
remove: vi.fn(),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
const transport = fakeTransport();
|
||||
const events = fakeWindowEvents();
|
||||
attachSessionEventHandler({ manager, transport, windowEvents: events.api });
|
||||
events.emit(9999);
|
||||
await Promise.resolve();
|
||||
expect(transport.sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("dispose() removes the listener", () => {
|
||||
const manager = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 1),
|
||||
remove: vi.fn(),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
const transport = fakeTransport();
|
||||
const events = fakeWindowEvents();
|
||||
const handle = attachSessionEventHandler({
|
||||
manager,
|
||||
transport,
|
||||
windowEvents: events.api,
|
||||
});
|
||||
expect(events.listenerCount()).toBe(1);
|
||||
handle.dispose();
|
||||
expect(events.listenerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentWindowApi } from "../agent-window";
|
||||
import { SessionManager } from "../manager";
|
||||
|
||||
function fakeAgentWindow(): AgentWindowApi & {
|
||||
createMock: ReturnType<typeof vi.fn>;
|
||||
removeMock: ReturnType<typeof vi.fn>;
|
||||
ensureActiveTabMock: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
let nextId = 100;
|
||||
const createMock = vi.fn(async (_url: string) => {
|
||||
const id = nextId++;
|
||||
return id;
|
||||
});
|
||||
const removeMock = vi.fn(async (_id: number) => {});
|
||||
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => {});
|
||||
return {
|
||||
create: createMock,
|
||||
remove: removeMock,
|
||||
ensureActiveTab: ensureActiveTabMock,
|
||||
createMock,
|
||||
removeMock,
|
||||
ensureActiveTabMock,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionManager", () => {
|
||||
it("creates an Agent Window when starting a session", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
const sm = new SessionManager({ agentWindow: aw, now: () => 1700000000000 });
|
||||
const ctx = await sm.start("aa11");
|
||||
expect(aw.createMock).toHaveBeenCalledOnce();
|
||||
expect(aw.createMock).toHaveBeenCalledWith("about:blank");
|
||||
expect(aw.ensureActiveTabMock).toHaveBeenCalledOnce();
|
||||
expect(aw.ensureActiveTabMock).toHaveBeenCalledWith(100, "about:blank");
|
||||
expect(ctx.sessionId).toBe("aa11");
|
||||
expect(ctx.agentWindowId).toBe(100);
|
||||
expect(ctx.createdAtMs).toBe(1700000000000);
|
||||
expect(ctx.refStore.isEmpty()).toBe(true);
|
||||
expect(ctx.borrowedTabs.size).toBe(0);
|
||||
});
|
||||
|
||||
it("indexes the session by sessionId and agent window id", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
const ctx = await sm.start("aa11");
|
||||
expect(sm.has("aa11")).toBe(true);
|
||||
expect(sm.get("aa11")).toBe(ctx);
|
||||
expect(sm.findByWindowId(ctx.agentWindowId)).toBe(ctx);
|
||||
expect(sm.findByWindowId(99999)).toBeNull();
|
||||
expect(sm.list().length).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects starting the same session twice", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
await sm.start("aa11");
|
||||
await expect(sm.start("aa11")).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it("stop() closes the Agent Window and forgets the session", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
const ctx = await sm.start("aa11");
|
||||
const removed = await sm.stop("aa11");
|
||||
expect(removed).toBe(ctx);
|
||||
expect(aw.removeMock).toHaveBeenCalledWith(ctx.agentWindowId);
|
||||
expect(sm.has("aa11")).toBe(false);
|
||||
expect(sm.findByWindowId(ctx.agentWindowId)).toBeNull();
|
||||
});
|
||||
|
||||
it("stop({ dropOnly: true }) skips the chrome.windows.remove call", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
await sm.start("aa11");
|
||||
await sm.stop("aa11", { dropOnly: true });
|
||||
expect(aw.removeMock).not.toHaveBeenCalled();
|
||||
expect(sm.has("aa11")).toBe(false);
|
||||
});
|
||||
|
||||
it("stopAll() drops every session and returns their ids", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
await sm.start("aa11");
|
||||
await sm.start("bb22");
|
||||
const dropped = await sm.stopAll();
|
||||
expect(dropped.sort()).toEqual(["aa11", "bb22"]);
|
||||
expect(sm.list()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("findBorrowingSession", () => {
|
||||
it("returns null when no session has borrowed the tab", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
await sm.start("aa11");
|
||||
expect(sm.findBorrowingSession(42, "aa11")).toBeNull();
|
||||
expect(sm.findBorrowingSession(42, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores borrows held by the calling session itself", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.borrowedTabs.set(42, { tabId: 42, originalWindowId: 7, originalIndex: 3 });
|
||||
expect(sm.findBorrowingSession(42, "aa11")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the borrowing session id when a different session holds the tab", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const a = await sm.start("aa11");
|
||||
await sm.start("bb22");
|
||||
a.borrowedTabs.set(42, { tabId: 42, originalWindowId: 7, originalIndex: 3 });
|
||||
expect(sm.findBorrowingSession(42, "bb22")).toBe("aa11");
|
||||
// currentSessionId=null asks "is anyone borrowing this tab?"
|
||||
expect(sm.findBorrowingSession(42, null)).toBe("aa11");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RefStore } from "../ref-store";
|
||||
|
||||
describe("RefStore", () => {
|
||||
it("stores and resolves ref → backendNodeId", () => {
|
||||
const s = new RefStore();
|
||||
s.set("e1", 42, { tabId: 7 });
|
||||
expect(s.resolve("@e1")).toBe(42);
|
||||
expect(s.resolve("e1")).toBe(42);
|
||||
expect(s.resolve("@e1", { tabId: 7 })).toBe(42);
|
||||
expect(s.resolve("@e1", { tabId: 8 })).toBeNull();
|
||||
expect(s.resolveEntry("@e1")).toMatchObject({
|
||||
backendNodeId: 42,
|
||||
tabId: 7,
|
||||
generation: 0,
|
||||
});
|
||||
expect(s.size()).toBe(1);
|
||||
expect(s.isEmpty()).toBe(false);
|
||||
});
|
||||
|
||||
it("replace() clears prior entries", () => {
|
||||
const s = new RefStore();
|
||||
s.set("@e1", 1);
|
||||
s.set("@e2", 2);
|
||||
s.replace([
|
||||
["e10", { backendNodeId: 10, tabId: 1 }],
|
||||
["@e11", { backendNodeId: 11, tabId: 1 }],
|
||||
]);
|
||||
expect(s.resolve("@e1")).toBeNull();
|
||||
expect(s.resolve("@e10")).toBe(10);
|
||||
expect(s.resolve("@e10", { tabId: 1 })).toBe(10);
|
||||
expect(s.resolve("@e10", { tabId: 2 })).toBeNull();
|
||||
expect(s.resolve("@e11")).toBe(11);
|
||||
expect(s.size()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Wrapper around `chrome.windows.*` for creating / closing the
|
||||
* dedicated Agent Window each session owns.
|
||||
*
|
||||
* Kept behind an interface so the SessionManager can be unit-tested
|
||||
* with a fake chrome.windows implementation (see
|
||||
* `__tests__/manager.test.ts`).
|
||||
*/
|
||||
|
||||
export interface AgentWindowApi {
|
||||
create(url: string): Promise<number>;
|
||||
remove(windowId: number): Promise<void>;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
ensureActiveTab(windowId: number, url: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Initial tab URL for every new session's Agent Window. */
|
||||
export const AGENT_WINDOW_HOME = "about:blank";
|
||||
|
||||
export const chromeAgentWindowApi: AgentWindowApi = {
|
||||
async create(url: string): Promise<number> {
|
||||
const win = await chrome.windows.create({
|
||||
type: "normal",
|
||||
focused: true,
|
||||
url,
|
||||
});
|
||||
if (typeof win?.id !== "number") {
|
||||
throw new Error("[bh] chrome.windows.create returned no window id");
|
||||
}
|
||||
return win.id;
|
||||
},
|
||||
async remove(windowId: number): Promise<void> {
|
||||
try {
|
||||
await chrome.windows.remove(windowId);
|
||||
} catch (err) {
|
||||
// Window may have been closed by the user already; ignore.
|
||||
console.debug("[bh] chrome.windows.remove failed", err);
|
||||
}
|
||||
},
|
||||
async ensureActiveTab(windowId: number, url: string): Promise<void> {
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const first = tabs.find((t) => typeof t.id === "number");
|
||||
if (first?.id) {
|
||||
if (!first.active) {
|
||||
await chrome.tabs.update(first.id, { active: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
await chrome.tabs.create({ windowId, url, active: true });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Transport } from "@/transport/transport";
|
||||
import type { EventFrame } from "@/transport/types";
|
||||
import type { SessionManager } from "./manager";
|
||||
|
||||
/**
|
||||
* Listener interface that mirrors `chrome.windows.onRemoved` so vitest
|
||||
* can drive the handler without a real Chrome runtime.
|
||||
*/
|
||||
export interface WindowRemovedListener {
|
||||
addListener(cb: (windowId: number) => void): void;
|
||||
removeListener(cb: (windowId: number) => void): void;
|
||||
}
|
||||
|
||||
export interface SessionEventHandlerOptions {
|
||||
manager: SessionManager;
|
||||
transport: Transport;
|
||||
windowEvents?: WindowRemovedListener;
|
||||
cdp?: {
|
||||
detachSession(sessionId: string): Promise<void>;
|
||||
};
|
||||
/**
|
||||
* Invoked after a user-closed Agent Window has been removed from the
|
||||
* SessionManager. Lets the caller refresh side caches such as the
|
||||
* `chrome.storage.session` "sessions live" flag (review M4/M5 I3).
|
||||
*/
|
||||
onSessionsChanged?: () => void;
|
||||
}
|
||||
|
||||
function chromeWindowEvents(): WindowRemovedListener {
|
||||
return {
|
||||
addListener: (cb) => chrome.windows.onRemoved.addListener(cb),
|
||||
removeListener: (cb) => chrome.windows.onRemoved.removeListener(cb),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for the user closing an Agent Window. When that happens we:
|
||||
* 1. Drop the local SessionContext (without trying to close the window
|
||||
* again — it's already gone).
|
||||
* 2. Emit a `session.window_closed` event to the daemon so it can
|
||||
* remove the session from its registry too.
|
||||
*
|
||||
* Returns a disposer that detaches the listener (used in tests).
|
||||
*/
|
||||
export function attachSessionEventHandler(options: SessionEventHandlerOptions): {
|
||||
dispose: () => void;
|
||||
} {
|
||||
const { manager, transport, onSessionsChanged } = options;
|
||||
const events = options.windowEvents ?? chromeWindowEvents();
|
||||
|
||||
const onRemoved = (windowId: number): void => {
|
||||
const ctx = manager.findByWindowId(windowId);
|
||||
if (!ctx) return;
|
||||
const returnFailures = Array.from(ctx.borrowedTabs.keys()).map((tabId) => ({
|
||||
tab_id: tabId,
|
||||
code: "cdp_failed",
|
||||
message: "Agent Window was closed before borrowed tab could be returned",
|
||||
}));
|
||||
if (returnFailures.length > 0) {
|
||||
console.warn(
|
||||
`[bh] Agent Window ${windowId} closed with borrowed tabs that could not be returned`,
|
||||
returnFailures,
|
||||
);
|
||||
}
|
||||
const detach = options.cdp
|
||||
? options.cdp.detachSession(ctx.sessionId).catch((err) => {
|
||||
console.debug("[bh] session-event cdp detach failed", err);
|
||||
})
|
||||
: Promise.resolve();
|
||||
void detach
|
||||
.then(() => manager.stop(ctx.sessionId, { dropOnly: true }))
|
||||
.then(() => {
|
||||
onSessionsChanged?.();
|
||||
const event: EventFrame = {
|
||||
event: "session.window_closed",
|
||||
payload: {
|
||||
session_id: ctx.sessionId,
|
||||
reason: "user_closed_window",
|
||||
...(returnFailures.length > 0 ? { return_failures: returnFailures } : {}),
|
||||
},
|
||||
};
|
||||
try {
|
||||
transport.send(event);
|
||||
} catch (err) {
|
||||
console.warn("[bh] could not push session.window_closed event", err);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[bh] session-event handler failed", err);
|
||||
});
|
||||
};
|
||||
|
||||
events.addListener(onRemoved);
|
||||
return {
|
||||
dispose: () => events.removeListener(onRemoved),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window";
|
||||
import { RefStore } from "./ref-store";
|
||||
|
||||
export interface SessionContext {
|
||||
sessionId: string;
|
||||
agentWindowId: number;
|
||||
refStore: RefStore;
|
||||
borrowedTabs: Map<number, BorrowedTab>;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface BorrowedTab {
|
||||
tabId: number;
|
||||
originalWindowId: number;
|
||||
originalIndex: number;
|
||||
}
|
||||
|
||||
export interface BorrowReservation {
|
||||
release(): void;
|
||||
commit(entry: BorrowedTab): void;
|
||||
}
|
||||
|
||||
export interface SessionManagerOptions {
|
||||
agentWindow?: AgentWindowApi;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner of all live agent sessions inside the extension.
|
||||
*
|
||||
* The daemon side has its own `SessionRegistry`; this class is the
|
||||
* extension-side mirror that holds the per-session Agent Window id,
|
||||
* ref-store, and borrowed-tab table. Tool implementations (M6+) read
|
||||
* from here to map a `session_id` back to "which Chrome window /
|
||||
* which ref / which borrowed tab".
|
||||
*
|
||||
* Designed to be unit-testable: chrome.* is injected via `AgentWindowApi`
|
||||
* so vitest never touches a real `chrome.windows` object.
|
||||
*/
|
||||
export class SessionManager {
|
||||
private readonly sessions = new Map<string, SessionContext>();
|
||||
private readonly windowIndex = new Map<number, string>();
|
||||
private readonly borrowReservations = new Map<number, string>();
|
||||
private readonly agentWindow: AgentWindowApi;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: SessionManagerOptions = {}) {
|
||||
this.agentWindow = options.agentWindow ?? chromeAgentWindowApi;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
has(sessionId: string): boolean {
|
||||
return this.sessions.has(sessionId);
|
||||
}
|
||||
|
||||
get(sessionId: string): SessionContext | null {
|
||||
return this.sessions.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
findByWindowId(windowId: number): SessionContext | null {
|
||||
const id = this.windowIndex.get(windowId);
|
||||
return id ? (this.sessions.get(id) ?? null) : null;
|
||||
}
|
||||
|
||||
list(): SessionContext[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up whether `tabId` is currently borrowed by some *other*
|
||||
* session than the one calling. Used by M8 `tab_borrow` to refuse
|
||||
* a second borrow on the same Chrome tab, and by `tab_close` to
|
||||
* tell apart "user tab" from "another session's borrowed tab"
|
||||
* (which we must not allow direct access to).
|
||||
*
|
||||
* Returns the borrowing session id when applicable, otherwise null.
|
||||
*/
|
||||
findBorrowingSession(tabId: number, currentSessionId: string | null): string | null {
|
||||
for (const ctx of this.sessions.values()) {
|
||||
if (ctx.sessionId === currentSessionId) continue;
|
||||
if (ctx.borrowedTabs.has(tabId)) return ctx.sessionId;
|
||||
}
|
||||
const reservedBy = this.borrowReservations.get(tabId);
|
||||
if (reservedBy && reservedBy !== currentSessionId) return reservedBy;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve a tab for `tool.tab_borrow` before the handler performs any
|
||||
* awaited Chrome work. This closes the cross-session race between the
|
||||
* "is anyone borrowing this tab?" check and the eventual borrowedTabs
|
||||
* write after `chrome.tabs.move`.
|
||||
*/
|
||||
tryReserveBorrow(tabId: number, sessionId: string): BorrowReservation | { borrowedBy: string } {
|
||||
const borrowedBy = this.findBorrowingSession(tabId, sessionId);
|
||||
if (borrowedBy) return { borrowedBy };
|
||||
this.borrowReservations.set(tabId, sessionId);
|
||||
let closed = false;
|
||||
const release = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (this.borrowReservations.get(tabId) === sessionId) {
|
||||
this.borrowReservations.delete(tabId);
|
||||
}
|
||||
};
|
||||
return {
|
||||
release,
|
||||
commit: (entry) => {
|
||||
if (closed) return;
|
||||
const ctx = this.sessions.get(sessionId);
|
||||
if (!ctx) {
|
||||
release();
|
||||
throw new Error(`session ${sessionId} disappeared during tab_borrow`);
|
||||
}
|
||||
if (this.borrowReservations.get(tabId) !== sessionId) {
|
||||
throw new Error(`tab ${tabId} borrow reservation disappeared before commit`);
|
||||
}
|
||||
ctx.borrowedTabs.set(tabId, entry);
|
||||
release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spin up a fresh session: open a new Agent Window with an
|
||||
* `about:blank` tab and register the context.
|
||||
*
|
||||
* Returns the created window id so callers can echo it back to the
|
||||
* daemon in the `tool.session_start` reply.
|
||||
*/
|
||||
async start(sessionId: string): Promise<SessionContext> {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
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);
|
||||
const ctx: SessionContext = {
|
||||
sessionId,
|
||||
agentWindowId: windowId,
|
||||
refStore: new RefStore(),
|
||||
borrowedTabs: new Map(),
|
||||
createdAtMs: this.now(),
|
||||
};
|
||||
this.sessions.set(sessionId, ctx);
|
||||
this.windowIndex.set(windowId, sessionId);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down a session: close its Agent Window and drop the context.
|
||||
*
|
||||
* `dropOnly = true` skips closing the window — used when the user
|
||||
* already closed it manually (M5.4 path) so we don't accidentally
|
||||
* close a window that has been re-purposed.
|
||||
*/
|
||||
async stop(
|
||||
sessionId: string,
|
||||
options: { dropOnly?: boolean } = {},
|
||||
): Promise<SessionContext | null> {
|
||||
const ctx = this.sessions.get(sessionId);
|
||||
if (!ctx) return null;
|
||||
if (!options.dropOnly) {
|
||||
await this.agentWindow.remove(ctx.agentWindowId);
|
||||
}
|
||||
this.sessions.delete(sessionId);
|
||||
this.windowIndex.delete(ctx.agentWindowId);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort cleanup of every live session (emergency brake / SW
|
||||
* shutdown). Returns the set of `session_id`s that were removed.
|
||||
*/
|
||||
async stopAll(options: { dropOnly?: boolean } = {}): Promise<string[]> {
|
||||
const ids = Array.from(this.sessions.keys());
|
||||
for (const id of ids) {
|
||||
await this.stop(id, options);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Per-session map from `@e<N>` snapshot refs to CDP `backendNodeId`.
|
||||
*
|
||||
* Each fresh `tool.snapshot` resets the store: M6 will call
|
||||
* `replace(...)` with the new ref → backendNodeId pairs. Tools like
|
||||
* `tool.click` consume the store via `resolve("@e1")`.
|
||||
*
|
||||
* Refs are session-scoped (§7): looking up a ref in the wrong session
|
||||
* returns `null`, never silently leaks. Storing values in different
|
||||
* sessions is fine; they live in independent `RefStore` instances
|
||||
* inside the `SessionContext`.
|
||||
*/
|
||||
export type BackendNodeId = number;
|
||||
|
||||
export interface RefEntry {
|
||||
backendNodeId: BackendNodeId;
|
||||
tabId: number | null;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
type RefInput = BackendNodeId | { backendNodeId: BackendNodeId; tabId: number };
|
||||
|
||||
export class RefStore {
|
||||
private readonly map = new Map<string, RefEntry>();
|
||||
private generation = 0;
|
||||
|
||||
size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.map.size === 0;
|
||||
}
|
||||
|
||||
resolve(ref: string, opts: { tabId?: number } = {}): BackendNodeId | null {
|
||||
const entry = this.map.get(normaliseRef(ref));
|
||||
if (!entry) return null;
|
||||
if (opts.tabId !== undefined && entry.tabId !== opts.tabId) return null;
|
||||
return entry.backendNodeId;
|
||||
}
|
||||
|
||||
resolveEntry(ref: string): RefEntry | null {
|
||||
return this.map.get(normaliseRef(ref)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire store with a new ref → backendNodeId mapping.
|
||||
* Used after every fresh `tool.snapshot`.
|
||||
*/
|
||||
replace(entries: Iterable<readonly [string, RefInput]>): void {
|
||||
this.map.clear();
|
||||
this.generation += 1;
|
||||
for (const [ref, input] of entries) this.map.set(normaliseRef(ref), this.entry(input));
|
||||
}
|
||||
|
||||
set(ref: string, id: BackendNodeId, opts: { tabId?: number } = {}): void {
|
||||
this.map.set(normaliseRef(ref), {
|
||||
backendNodeId: id,
|
||||
tabId: opts.tabId ?? null,
|
||||
generation: this.generation,
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
entries(): IterableIterator<[string, RefEntry]> {
|
||||
return this.map.entries();
|
||||
}
|
||||
|
||||
private entry(input: RefInput): RefEntry {
|
||||
if (typeof input === "number") {
|
||||
return {
|
||||
backendNodeId: input,
|
||||
tabId: null,
|
||||
generation: this.generation,
|
||||
};
|
||||
}
|
||||
return {
|
||||
backendNodeId: input.backendNodeId,
|
||||
tabId: input.tabId,
|
||||
generation: this.generation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical RefStore key: `@e3` and `e3` both become `e3`. */
|
||||
export function normaliseRef(ref: string): string {
|
||||
return ref.startsWith("@") ? ref.slice(1) : ref;
|
||||
}
|
||||
Reference in New Issue
Block a user