Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "atwebpilot-monorepo",
"private": true,
"version": "0.0.73",
"version": "0.0.74",
"description": "AtWebPilot — AI 网页助手(侧边面板)monorepo root",
"type": "module",
"scripts": {
Expand Down
20 changes: 20 additions & 0 deletions packages/coordinator/src/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export class Coordinator {
}

closeSession(id: string): void {
const session = this.sessions.get(id);
this.sessions.close(id);
if (session) this.notifySessionClosed(session);
}

// === Tool calls ===
Expand Down Expand Up @@ -112,6 +114,24 @@ export class Coordinator {
// === Periodic housekeeping ===
tick(): { expired_sessions: string[] } {
const expired_sessions = this.sessions.tick();
for (const id of expired_sessions) {
const session = this.sessions.get(id);
if (session) this.notifySessionClosed(session);
}
return { expired_sessions };
}

private notifySessionClosed(session: Session): void {
void this.hub
.send(session.worker_id, {
type: "CLOSE_SESSION",
nonce: this.idGen.next("nonce"),
ts: this.clock.now(),
protocol_version: PROTOCOL_VERSION,
session_id: session.id
})
.catch(() => {
// A disconnected worker releases all of its claims on the extension side.
});
}
}
14 changes: 12 additions & 2 deletions packages/coordinator/tests/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function makeWorker(id: string, overrides: Partial<Worker> = {}): Worker {

describe("Coordinator happy path", () => {
it("worker register → open session → list tools → call submitForm → close", () => {
const { coord } = newCoord();
const { coord, hub } = newCoord();
coord.registerWorker(makeWorker("w1"));

const session = coord.openSession({
Expand All @@ -71,8 +71,13 @@ describe("Coordinator happy path", () => {
if (validate.ok) coord.recordCall(session.id, validate.dangerous);
expect(coord.sessions.get(session.id)?.dangerous_count).toBe(1);

vi.mocked(hub.send).mockClear();
coord.closeSession(session.id);
expect(coord.sessions.get(session.id)?.state).toBe("closed");
expect(hub.send).toHaveBeenCalledWith("w1", expect.objectContaining({
type: "CLOSE_SESSION",
session_id: session.id
}));
});
});

Expand Down Expand Up @@ -101,18 +106,23 @@ describe("Coordinator denials", () => {

describe("Coordinator periodic tick", () => {
it("expires idle sessions", () => {
const { coord, clock } = newCoord();
const { coord, clock, hub } = newCoord();
coord.registerWorker(makeWorker("w1"));
const session = coord.openSession({
ai_client_fingerprint: "ai-1",
worker_id: "w1",
tab_id: "t1",
scope: new Set([])
});
vi.mocked(hub.send).mockClear();
clock.tick(SESSION_IDLE_TIMEOUT_MS + 1);
const { expired_sessions } = coord.tick();
expect(expired_sessions).toContain(session.id);
expect(coord.sessions.get(session.id)?.state).toBe("expired");
expect(hub.send).toHaveBeenCalledWith("w1", expect.objectContaining({
type: "CLOSE_SESSION",
session_id: session.id
}));
});
});

Expand Down
5 changes: 4 additions & 1 deletion packages/extension/src/background/coordinator-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ export class CoordinatorPool {
async add(meta: Omit<PoolEntry, "status" | "failures">): Promise<void> {
if (this.entries.has(meta.sessionId)) return;

const sharedOptions = this.deps.clientOptions(meta.endpoint, meta.sessionId);
const client = new CoordinatorClient({
...this.deps.clientOptions(meta.endpoint, meta.sessionId),
...sharedOptions,
ws_url: meta.endpoint,
onStatusChange: (status) => {
sharedOptions.onStatusChange?.(status);
const e = this.entries.get(meta.sessionId);
if (!e) return;
// Dormancy outranks a plain "disconnected": it means nobody is coming
Expand All @@ -82,6 +84,7 @@ export class CoordinatorPool {
this.changed();
},
onDormant: () => {
sharedOptions.onDormant?.();
const e = this.entries.get(meta.sessionId);
if (!e) return;
e.status = "dormant";
Expand Down
7 changes: 7 additions & 0 deletions packages/extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
sendResponse({ sessions: pool ? pool.list() : [] });
return false;
}
if (m?.type === "pairing.cleanupGroups") {
void tabSessionGroups
.cleanupBySource("mcp")
.then((cleanedTabs) => sendResponse({ ok: true, cleanedTabs }))
.catch((error: unknown) => sendResponse({ ok: false, error: String(error) }));
return true;
}
if (m?.type === "pairing.disconnect" && typeof m.sessionId === "string") {
void ensurePool().remove(m.sessionId).then(() => sendResponse({ ok: true }));
return true;
Expand Down
51 changes: 47 additions & 4 deletions packages/extension/src/background/tab-session-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type Deps = {
ungroupTabs(tabIds: number | number[]): Promise<void>;
moveTab(tabId: number, move: chrome.tabs.MoveProperties): Promise<chrome.tabs.Tab | chrome.tabs.Tab[]>;
getGroup(groupId: number): Promise<chrome.tabGroups.TabGroup>;
queryGroups(query: chrome.tabGroups.QueryInfo): Promise<chrome.tabGroups.TabGroup[]>;
queryTabs(query: chrome.tabs.QueryInfo): Promise<chrome.tabs.Tab[]>;
updateGroup(groupId: number, update: chrome.tabGroups.UpdateProperties): Promise<chrome.tabGroups.TabGroup>;
load(): Promise<PersistedState | undefined>;
save(state: PersistedState): Promise<void>;
Expand Down Expand Up @@ -128,6 +130,45 @@ export class TabSessionGroupManager {
return this.enqueue(() => this.releaseSessionNow(sessionId));
}

releaseBySource(source: TabSessionSource): Promise<void> {
return this.enqueue(async () => {
const sessionIds = new Set(
[...this.claims.values()]
.filter((claim) => claim.source === source)
.map((claim) => claim.sessionId)
);
for (const sessionId of sessionIds) await this.releaseSessionNow(sessionId);
});
}

/** Explicit user cleanup, including branded groups whose persisted claim was lost. */
cleanupBySource(source: TabSessionSource): Promise<number> {
return this.enqueue(async () => {
const trackedTabIds = [...this.claims.values()]
.filter((claim) => claim.source === source)
.map((claim) => claim.tabId);
const sessionIds = new Set(
[...this.claims.values()]
.filter((claim) => claim.source === source)
.map((claim) => claim.sessionId)
);
for (const sessionId of sessionIds) await this.releaseSessionNow(sessionId);

const cleaned = new Set(trackedTabIds);
const prefix = `AtWebPilot · ${sourceLabel(source)} ·`;
const groups = await this.deps.queryGroups({});
for (const group of groups) {
if (!group.title?.startsWith(prefix)) continue;
const tabs = await this.deps.queryTabs({ groupId: group.id });
const tabIds = tabs.flatMap((tab) => tab.id == null ? [] : [tab.id]);
if (tabIds.length === 0) continue;
await this.deps.ungroupTabs(tabIds);
for (const tabId of tabIds) cleaned.add(tabId);
}
return cleaned.size;
});
}

private async releaseSessionNow(sessionId: string): Promise<void> {
const tabIds = [...this.claims.values()]
.filter((claim) => claim.sessionId === sessionId)
Expand Down Expand Up @@ -235,18 +276,18 @@ export class TabSessionGroupManager {
return this.deps.save(this.snapshot());
}

private enqueue(operation: () => Promise<void>): Promise<void> {
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const run = this.operations.then(
async () => {
await this.ready;
await operation();
return operation();
},
async () => {
await this.ready;
await operation();
return operation();
}
);
this.operations = run.catch(() => undefined);
this.operations = run.then(() => undefined, () => undefined);
return run;
}
}
Expand Down Expand Up @@ -278,6 +319,8 @@ function chromeDeps(): Deps {
ungroupTabs: (tabIds) => chrome.tabs.ungroup(tabIds),
moveTab: (tabId, move) => chrome.tabs.move(tabId, move),
getGroup: (groupId) => chrome.tabGroups.get(groupId),
queryGroups: (query) => chrome.tabGroups.query(query),
queryTabs: (query) => chrome.tabs.query(query),
updateGroup: (groupId, update) => chrome.tabGroups.update(groupId, update),
load: async () => (await chrome.storage.session.get([STORAGE_KEY]))[STORAGE_KEY] as PersistedState | undefined,
save: (state) => chrome.storage.session.set({ [STORAGE_KEY]: state })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function CoordinatorSettingsPage() {
const [cdpEnabled, setCdpEnabled] = useState(false);
const [sessions, setSessions] = useState<PoolEntry[]>([]);
const [trusted, setTrusted] = useState<TrustRecord[]>([]);
const [cleaningGroups, setCleaningGroups] = useState(false);

useEffect(() => {
void (async () => {
Expand Down Expand Up @@ -290,7 +291,31 @@ export function CoordinatorSettingsPage() {
</label>

<section className="border-t pt-3">
<h3 className="text-sm font-medium mb-1">已接入的会话</h3>
<div className="mb-1 flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">已接入的会话</h3>
<button
type="button"
className="text-xs text-gray-600 underline disabled:opacity-50"
disabled={cleaningGroups}
title="恢复或取消 AtWebPilot 创建的 MCP 标签分组,不会关闭标签页"
onClick={() => {
setCleaningGroups(true);
void chrome.runtime
.sendMessage({ type: "pairing.cleanupGroups" })
.then((result: { ok?: boolean; cleanedTabs?: number; error?: string } | undefined) => {
setSavedMsg(
result?.ok
? `已取消 ${result.cleanedTabs ?? 0} 个标签页的 MCP 分组,标签页保持打开`
: result?.error ?? "清理失败"
);
})
.catch((error: unknown) => setSavedMsg(`清理失败:${String(error)}`))
.finally(() => setCleaningGroups(false));
}}
>
{cleaningGroups ? "清理中…" : "取消所有 MCP 分组"}
</button>
</div>
{sessions.length === 0 ? (
<p className="text-xs text-gray-500">
还没有会话接入。在 Claude Code 里让 AI 操作网页时,会自动打开一个配对页请你确认。
Expand Down
19 changes: 19 additions & 0 deletions packages/extension/tests/background/coordinator-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ describe("CoordinatorPool", () => {
expect(FakeWS.instances[0].readyState).toBe(FakeWS.CLOSED);
});

it("preserves the caller status callback when disconnecting", async () => {
const onStatusChange = vi.fn();
const pool = new CoordinatorPool({
clientOptions: () => ({
token: "t",
worker_id: "w1",
savedToolsProvider: async () => [],
labelsProvider: async () => [],
onStatusChange
})
});
await pool.addFromPairing(payload());
onStatusChange.mockClear();

await pool.remove("sess_1");

expect(onStatusChange).toHaveBeenCalledWith("disconnected");
});

it("removing an unknown session is a no-op", async () => {
const pool = makePool();
await expect(pool.remove("nope")).resolves.toBeUndefined();
Expand Down
37 changes: 37 additions & 0 deletions packages/extension/tests/background/tab-session-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ function harness(seed: Array<{ id: number; windowId: number; index: number; grou
if (!group) throw new Error("group missing");
return group as chrome.tabGroups.TabGroup;
}),
queryGroups: vi.fn(async () => [...groups.values()] as chrome.tabGroups.TabGroup[]),
queryTabs: vi.fn(async (query: chrome.tabs.QueryInfo) =>
[...tabs.values()].filter((tab) => query.groupId == null || tab.groupId === query.groupId) as chrome.tabs.Tab[]
),
updateGroup: vi.fn(async (groupId: number, update: chrome.tabGroups.UpdateProperties) => {
const group = groups.get(groupId);
if (!group) throw new Error("group missing");
Expand Down Expand Up @@ -109,6 +113,39 @@ describe("TabSessionGroupManager", () => {
expect([...h.groups.values()][0].title).toContain("等待确认");
});

it("releases only claims from the requested source", async () => {
const h = harness([
{ id: 1, windowId: 7, index: 0 },
{ id: 2, windowId: 7, index: 1 }
]);
await h.manager.claim({ sessionId: "mcp-s1", tabId: 1, source: "mcp" });
await h.manager.claim({ sessionId: "local-s1", tabId: 2, source: "local" });

await h.manager.releaseBySource("mcp");

expect(h.tabs.get(1)?.groupId).toBe(-1);
expect(h.tabs.get(2)?.groupId).not.toBe(-1);
expect(h.manager.snapshot().claims).toEqual([
expect.objectContaining({ sessionId: "local-s1", tabId: 2 })
]);
});

it("explicit cleanup ungroups branded orphan groups without closing tabs", async () => {
const h = harness([{ id: 1, windowId: 7, index: 0, groupId: 55 }]);
h.groups.set(55, {
id: 55,
windowId: 7,
title: "AtWebPilot · MCP · stale1",
color: "green"
});

const cleanedTabs = await h.manager.cleanupBySource("mcp");

expect(cleanedTabs).toBe(1);
expect(h.tabs.has(1)).toBe(true);
expect(h.tabs.get(1)?.groupId).toBe(-1);
});

it("serializes a session release behind an in-flight claim", async () => {
const h = harness([{ id: 1, windowId: 7, index: 3 }]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ function fakeChromeStorage(initial: Record<string, unknown> = {}) {
for (const fn of listeners) fn(changes, "local");
})
};
const sendMessage = vi.fn(async (_message?: unknown): Promise<unknown> => ({ sessions: [] }));
return {
// Plan 33: the page polls the worker for connected sessions.
runtime: { sendMessage: vi.fn(async () => ({ sessions: [] })) },
runtime: { sendMessage },
permissions: { contains: vi.fn(async () => false) },
storage: {
local,
Expand Down Expand Up @@ -180,6 +181,34 @@ describe("CoordinatorSettingsPage", () => {
});
});

it("cleans MCP groups without closing tabs", async () => {
const chromeMock = fakeChromeStorage();
chromeMock.runtime.sendMessage.mockImplementation(async (message?: unknown) => {
if ((message as { type?: string }).type === "pairing.cleanupGroups") {
return { ok: true, cleanedTabs: 3 };
}
return { sessions: [] };
});
vi.stubGlobal("chrome", chromeMock);
await act(async () => {
root.render(<CoordinatorSettingsPage />);
});
await flushAsync();

const cleanupButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("取消所有 MCP 分组")
) as HTMLButtonElement;
await act(async () => {
cleanupButton.click();
});
await flushAsync();

expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith({
type: "pairing.cleanupGroups"
});
expect(container.textContent).toContain("已取消 3 个标签页的 MCP 分组,标签页保持打开");
});

it("does not show a stale connected runtime status as live", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-07T12:00:00Z"));
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@attson/atwebpilot-mcp",
"version": "0.0.73",
"version": "0.0.74",
"license": "Apache-2.0",
"description": "MCP server for the atwebpilot browser extension - let Codex or Claude Code drive your browser.",
"homepage": "https://github.com/attson/atwebpilot#readme",
Expand Down
Loading
Loading