Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
@@ -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;
}