From 25a9f284f8f9513bb83f85112d0c2c3032e19a86 Mon Sep 17 00:00:00 2001 From: liuzaisen Date: Mon, 21 Sep 2026 20:39:22 +0800 Subject: [PATCH 1/3] fix(mcp): clean up session tab groups --- packages/coordinator/src/coordinator.ts | 20 ++++++++ .../coordinator/tests/coordinator.test.ts | 14 ++++- .../src/background/coordinator-pool.ts | 5 +- packages/extension/src/background/index.ts | 7 +++ .../src/background/tab-session-groups.ts | 51 +++++++++++++++++-- .../pages/coordinator-settings-page.tsx | 27 +++++++++- .../tests/background/coordinator-pool.test.ts | 19 +++++++ .../background/tab-session-groups.test.ts | 37 ++++++++++++++ .../pages/coordinator-settings-page.test.tsx | 28 ++++++++++ packages/mcp-server/src/ensure-hub.ts | 8 +++ 10 files changed, 208 insertions(+), 8 deletions(-) diff --git a/packages/coordinator/src/coordinator.ts b/packages/coordinator/src/coordinator.ts index e8493f7..83139c4 100644 --- a/packages/coordinator/src/coordinator.ts +++ b/packages/coordinator/src/coordinator.ts @@ -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 === @@ -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. + }); + } } diff --git a/packages/coordinator/tests/coordinator.test.ts b/packages/coordinator/tests/coordinator.test.ts index df8b40a..98f0aa9 100644 --- a/packages/coordinator/tests/coordinator.test.ts +++ b/packages/coordinator/tests/coordinator.test.ts @@ -47,7 +47,7 @@ function makeWorker(id: string, overrides: Partial = {}): 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({ @@ -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 + })); }); }); @@ -101,7 +106,7 @@ 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", @@ -109,10 +114,15 @@ describe("Coordinator periodic tick", () => { 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 + })); }); }); diff --git a/packages/extension/src/background/coordinator-pool.ts b/packages/extension/src/background/coordinator-pool.ts index cf3399f..272ea5a 100644 --- a/packages/extension/src/background/coordinator-pool.ts +++ b/packages/extension/src/background/coordinator-pool.ts @@ -69,10 +69,12 @@ export class CoordinatorPool { async add(meta: Omit): Promise { 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 @@ -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"; diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index fdd090b..44ce701 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -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; diff --git a/packages/extension/src/background/tab-session-groups.ts b/packages/extension/src/background/tab-session-groups.ts index 622c101..61ddc58 100644 --- a/packages/extension/src/background/tab-session-groups.ts +++ b/packages/extension/src/background/tab-session-groups.ts @@ -27,6 +27,8 @@ type Deps = { ungroupTabs(tabIds: number | number[]): Promise; moveTab(tabId: number, move: chrome.tabs.MoveProperties): Promise; getGroup(groupId: number): Promise; + queryGroups(query: chrome.tabGroups.QueryInfo): Promise; + queryTabs(query: chrome.tabs.QueryInfo): Promise; updateGroup(groupId: number, update: chrome.tabGroups.UpdateProperties): Promise; load(): Promise; save(state: PersistedState): Promise; @@ -128,6 +130,45 @@ export class TabSessionGroupManager { return this.enqueue(() => this.releaseSessionNow(sessionId)); } + releaseBySource(source: TabSessionSource): Promise { + 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 { + 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 { const tabIds = [...this.claims.values()] .filter((claim) => claim.sessionId === sessionId) @@ -235,18 +276,18 @@ export class TabSessionGroupManager { return this.deps.save(this.snapshot()); } - private enqueue(operation: () => Promise): Promise { + private enqueue(operation: () => Promise): Promise { 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; } } @@ -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 }) diff --git a/packages/extension/src/sidepanel/pages/coordinator-settings-page.tsx b/packages/extension/src/sidepanel/pages/coordinator-settings-page.tsx index eae9775..612fc55 100644 --- a/packages/extension/src/sidepanel/pages/coordinator-settings-page.tsx +++ b/packages/extension/src/sidepanel/pages/coordinator-settings-page.tsx @@ -65,6 +65,7 @@ export function CoordinatorSettingsPage() { const [cdpEnabled, setCdpEnabled] = useState(false); const [sessions, setSessions] = useState([]); const [trusted, setTrusted] = useState([]); + const [cleaningGroups, setCleaningGroups] = useState(false); useEffect(() => { void (async () => { @@ -290,7 +291,31 @@ export function CoordinatorSettingsPage() {
-

已接入的会话

+
+

已接入的会话

+ +
{sessions.length === 0 ? (

还没有会话接入。在 Claude Code 里让 AI 操作网页时,会自动打开一个配对页请你确认。 diff --git a/packages/extension/tests/background/coordinator-pool.test.ts b/packages/extension/tests/background/coordinator-pool.test.ts index c0d2990..aed4884 100644 --- a/packages/extension/tests/background/coordinator-pool.test.ts +++ b/packages/extension/tests/background/coordinator-pool.test.ts @@ -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(); diff --git a/packages/extension/tests/background/tab-session-groups.test.ts b/packages/extension/tests/background/tab-session-groups.test.ts index b2e73a6..483e5f4 100644 --- a/packages/extension/tests/background/tab-session-groups.test.ts +++ b/packages/extension/tests/background/tab-session-groups.test.ts @@ -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"); @@ -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 }]); diff --git a/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx b/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx index 5136651..40274f6 100644 --- a/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx +++ b/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx @@ -180,6 +180,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(); + }); + 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")); diff --git a/packages/mcp-server/src/ensure-hub.ts b/packages/mcp-server/src/ensure-hub.ts index 891401d..4cf3131 100644 --- a/packages/mcp-server/src/ensure-hub.ts +++ b/packages/mcp-server/src/ensure-hub.ts @@ -24,6 +24,7 @@ export type EnsureDeps = { }; const DEFAULT_WORKER_WAIT_TIMEOUT_MS = 90_000; +const SESSION_SWEEP_INTERVAL_MS = 30_000; /** * Binds the websocket port on first use rather than at startup. @@ -40,6 +41,7 @@ export function createHubEnsurer(d: EnsureDeps): Deps & { bound(): boolean } { let opening: Promise | null = null; let hubIsClosed = false; let pendingDenial = false; + let sessionSweepTimer: ReturnType | null = null; let workerWait: { promise: Promise; resolve: (workerId: string) => void; @@ -86,6 +88,10 @@ export function createHubEnsurer(d: EnsureDeps): Deps & { bound(): boolean } { function hubClosed(): void { hubIsClosed = true; + if (sessionSweepTimer) { + clearInterval(sessionSweepTimer); + sessionSweepTimer = null; + } if (workerWait) { finishWorkerWait({ error: new Error("浏览器配对服务已关闭。") }); } @@ -122,6 +128,8 @@ export function createHubEnsurer(d: EnsureDeps): Deps & { bound(): boolean } { saveLastPort(port, d.identityDir); const coordinator = new Coordinator({ hub, clock: d.clock, idGen: d.idGen }); + sessionSweepTimer = setInterval(() => coordinator.tick(), SESSION_SWEEP_INTERVAL_MS); + sessionSweepTimer.unref?.(); installWire(hub, coordinator, d.clock, () => notifyWorkerReady(coordinator)); pairUrlValue = `http://127.0.0.1:${port}/pair`; return { coordinator, hub, port }; From c368c0bc70bfd40485f3ce05308f9d4d94c22f8a Mon Sep 17 00:00:00 2001 From: liuzaisen Date: Mon, 21 Sep 2026 20:39:22 +0800 Subject: [PATCH 2/3] chore(release): v0.0.74 --- package.json | 2 +- packages/mcp-server/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 90175b7..ce6f55e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index fb4ed1a..f152185 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -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", From 6b3ceac7a2b9c961b5e35d7c4261aa3b236314e8 Mon Sep 17 00:00:00 2001 From: liuzaisen Date: Mon, 21 Sep 2026 20:41:47 +0800 Subject: [PATCH 3/3] test(extension): type coordinator settings message mock --- .../tests/sidepanel/pages/coordinator-settings-page.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx b/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx index 40274f6..f1afbfe 100644 --- a/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx +++ b/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx @@ -42,9 +42,10 @@ function fakeChromeStorage(initial: Record = {}) { for (const fn of listeners) fn(changes, "local"); }) }; + const sendMessage = vi.fn(async (_message?: unknown): Promise => ({ 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, @@ -182,7 +183,7 @@ describe("CoordinatorSettingsPage", () => { it("cleans MCP groups without closing tabs", async () => { const chromeMock = fakeChromeStorage(); - chromeMock.runtime.sendMessage.mockImplementation(async (message: unknown) => { + chromeMock.runtime.sendMessage.mockImplementation(async (message?: unknown) => { if ((message as { type?: string }).type === "pairing.cleanupGroups") { return { ok: true, cleanedTabs: 3 }; }