diff --git a/mcp/packages/browser-extension/README.md b/mcp/packages/browser-extension/README.md
index ad0a784fd3..b476b11656 100644
--- a/mcp/packages/browser-extension/README.md
+++ b/mcp/packages/browser-extension/README.md
@@ -12,11 +12,21 @@ for the plugin to answer again, and then runs the task.
session ID, MCP server URL, token and connection status.
- The service worker maps each session to its tab and opens one WebSocket per MCP server
and token, on the endpoint the plugin uses. It registers there as a wake agent.
-- When the server sends a wake request, the worker activates the tab and focuses its
- window.
+- When the server sends a wake request, the worker wakes the tab as described below.
The popup turns waking on or off and lists the paired sessions and server connections.
+## How a tab is woken
+
+- The extension unfreezes the page in place: it attaches the debugger to the tab, sets the
+ page lifecycle state to active, and detaches, without switching tabs. Chrome shows a
+ debugging notice while the extension is attached. Chrome does not allow `debugger` as an
+ optional permission, so the extension requires it, and Chrome warns about it at install.
+- **Fallback**, when the debugger fails (for instance with DevTools open on the tab): the
+ extension brings the tab forward, waits until the page reports that it runs again, then
+ returns to the tab and window that had the focus. Whether the window comes forward depends
+ on the window manager.
+
## Build and load
From `mcp/`:
diff --git a/mcp/packages/browser-extension/public/manifest.json b/mcp/packages/browser-extension/public/manifest.json
index 3efcc9e3fc..c7377dddb4 100644
--- a/mcp/packages/browser-extension/public/manifest.json
+++ b/mcp/packages/browser-extension/public/manifest.json
@@ -4,7 +4,7 @@
"version": "0.1.0",
"description": "Wakes a background Penpot tab when an MCP task needs it.",
"minimum_chrome_version": "116",
- "permissions": ["storage", "alarms"],
+ "permissions": ["storage", "alarms", "debugger"],
"action": {
"default_title": "Penpot MCP Wake",
"default_popup": "popup.html"
diff --git a/mcp/packages/browser-extension/public/popup.css b/mcp/packages/browser-extension/public/popup.css
index 383ef77692..548ba73fe3 100644
--- a/mcp/packages/browser-extension/public/popup.css
+++ b/mcp/packages/browser-extension/public/popup.css
@@ -42,6 +42,11 @@ h2 {
align-items: center;
}
+.last-wake {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
ul {
margin: 0;
padding-left: 18px;
diff --git a/mcp/packages/browser-extension/public/popup.html b/mcp/packages/browser-extension/public/popup.html
index 15967ddc56..609d9ae2a1 100644
--- a/mcp/packages/browser-extension/public/popup.html
+++ b/mcp/packages/browser-extension/public/popup.html
@@ -11,6 +11,8 @@
Wake Penpot tabs for MCP tasks
+
Last wake
+
Paired sessions
MCP servers
diff --git a/mcp/packages/browser-extension/src/PageBridge.test.ts b/mcp/packages/browser-extension/src/PageBridge.test.ts
index 021aa0c0cf..3765ecece3 100644
--- a/mcp/packages/browser-extension/src/PageBridge.test.ts
+++ b/mcp/packages/browser-extension/src/PageBridge.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { beforeEach, test } from "node:test";
import type { ContentMessage } from "./messages.ts";
-import { PageBridge, type PageWindow, type WorkerChannel } from "./PageBridge.ts";
+import { PageBridge, type PageDocument, type PageWindow, type WorkerChannel } from "./PageBridge.ts";
const ORIGIN = "https://design.penpot.app";
@@ -29,6 +29,20 @@ class FakePage implements PageWindow {
}
}
+/** Document whose lifecycle events the test dispatches. */
+class FakeDocument implements PageDocument {
+ visibilityState: DocumentVisibilityState = "hidden";
+ private readonly listeners = new Map void)[]>();
+
+ addEventListener(type: string, listener: () => void): void {
+ this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
+ }
+
+ dispatch(type: string): void {
+ for (const listener of this.listeners.get(type) ?? []) listener();
+ }
+}
+
/** Worker channel recording what the bridge sends. */
class FakeWorker implements WorkerChannel {
readonly received: ContentMessage[] = [];
@@ -48,12 +62,14 @@ const announcement = {
};
let page: FakePage;
+let doc: FakeDocument;
let worker: FakeWorker;
beforeEach(() => {
page = new FakePage();
+ doc = new FakeDocument();
worker = new FakeWorker();
- new PageBridge(page, worker).start();
+ new PageBridge(page, doc, worker).start();
});
test("asks the page for its session on start, restricted to the page's origin", () => {
@@ -95,12 +111,22 @@ test("reports a hidden page and asks again when it returns from the back/forward
test("keeps working when the worker cannot be reached", () => {
const failing = new FakePage();
- new PageBridge(failing, {
+ new PageBridge(failing, new FakeDocument(), {
sendMessage: () => {
throw new Error("Extension context invalidated.");
},
}).start();
failing.post(announcement);
- new PageBridge(failing, { sendMessage: () => Promise.reject(new Error("No receiver")) }).start();
+ new PageBridge(failing, new FakeDocument(), {
+ sendMessage: () => Promise.reject(new Error("No receiver")),
+ }).start();
failing.post(announcement);
});
+
+test("tells the worker when the page resumes or becomes visible", () => {
+ doc.dispatch("resume");
+ doc.dispatch("visibilitychange");
+ doc.visibilityState = "visible";
+ doc.dispatch("visibilitychange");
+ assert.deepEqual(worker.received, [{ type: "page-resumed" }, { type: "page-resumed" }]);
+});
diff --git a/mcp/packages/browser-extension/src/PageBridge.ts b/mcp/packages/browser-extension/src/PageBridge.ts
index e6a6ddb4d1..f7c4e96fb6 100644
--- a/mcp/packages/browser-extension/src/PageBridge.ts
+++ b/mcp/packages/browser-extension/src/PageBridge.ts
@@ -8,6 +8,12 @@ export interface PageWindow {
addEventListener(type: string, listener: (event: any) => void): void;
}
+/** Document operations the bridge uses; the content script passes its document. */
+export interface PageDocument {
+ readonly visibilityState: DocumentVisibilityState;
+ addEventListener(type: string, listener: () => void): void;
+}
+
/** Channel from the content script to the service worker. */
export interface WorkerChannel {
sendMessage(message: ContentMessage): Promise;
@@ -17,12 +23,14 @@ export interface WorkerChannel {
* Relays the MCP session announced by a Penpot page to the extension's service worker.
*
* The bridge asks the page for its session on load, when the page reports it has started
- * listening, and when the page returns from the back/forward cache. It accepts only messages the page posted on its own window from its own
- * origin.
+ * listening, and when the page returns from the back/forward cache. It accepts only messages the
+ * page posted on its own window from its own origin. It also tells the worker when the page runs
+ * again after the browser froze or hid it.
*/
export class PageBridge {
constructor(
private readonly page: PageWindow,
+ private readonly document: PageDocument,
private readonly worker: WorkerChannel
) {}
@@ -35,6 +43,12 @@ export class PageBridge {
this.sayHello();
}
});
+ this.document.addEventListener("resume", () => this.send({ type: "page-resumed" }));
+ this.document.addEventListener("visibilitychange", () => {
+ if (this.document.visibilityState === "visible") {
+ this.send({ type: "page-resumed" });
+ }
+ });
this.sayHello();
}
diff --git a/mcp/packages/browser-extension/src/PopupState.test.ts b/mcp/packages/browser-extension/src/PopupState.test.ts
index df4f77aa3f..c873678523 100644
--- a/mcp/packages/browser-extension/src/PopupState.test.ts
+++ b/mcp/packages/browser-extension/src/PopupState.test.ts
@@ -16,9 +16,10 @@ test("lists the paired sessions and connections without the user tokens", async
}
);
const connections = [{ serverUrl: "wss://penpot/mcp/ws", status: "connected" as const, sessionIds: ["tab"] }];
- const state = getPopupState(false, sessions, connections);
+ const state = getPopupState(false, null, sessions, connections);
assert.deepEqual(state, {
enabled: false,
+ lastWake: null,
sessions: [{ sessionId: "tab", tabId: 7, serverUrl: "wss://penpot/mcp/ws" }],
connections,
});
diff --git a/mcp/packages/browser-extension/src/PopupState.ts b/mcp/packages/browser-extension/src/PopupState.ts
index c1d29600d8..9fe672b579 100644
--- a/mcp/packages/browser-extension/src/PopupState.ts
+++ b/mcp/packages/browser-extension/src/PopupState.ts
@@ -1,4 +1,5 @@
import type { SessionMap } from "./SessionMap.ts";
+import type { WakeRecord } from "./TabWaker.ts";
import type { ConnectionSummary } from "./WakeAgentConnections.ts";
/** Request of the popup for the extension's state. */
@@ -9,6 +10,7 @@ export interface PopupStateRequest {
/** State of the extension shown in the popup. */
export interface PopupState {
enabled: boolean;
+ lastWake: WakeRecord | null;
sessions: { sessionId: string; tabId: number; serverUrl: string }[];
connections: ConnectionSummary[];
}
@@ -17,12 +19,19 @@ export interface PopupState {
* Collects the state shown in the popup. It leaves out the user tokens.
*
* @param enabled - Whether waking is turned on
+ * @param lastWake - The last wake request, if any
* @param sessions - The map of sessions to tabs
* @param connections - The summaries of the server connections
*/
-export function getPopupState(enabled: boolean, sessions: SessionMap, connections: ConnectionSummary[]): PopupState {
+export function getPopupState(
+ enabled: boolean,
+ lastWake: WakeRecord | null,
+ sessions: SessionMap,
+ connections: ConnectionSummary[]
+): PopupState {
return {
enabled,
+ lastWake,
sessions: sessions.all().map(([sessionId, entry]) => ({
sessionId,
tabId: entry.tabId,
diff --git a/mcp/packages/browser-extension/src/TabWaker.test.ts b/mcp/packages/browser-extension/src/TabWaker.test.ts
index b386ae486f..aa136b32a0 100644
--- a/mcp/packages/browser-extension/src/TabWaker.test.ts
+++ b/mcp/packages/browser-extension/src/TabWaker.test.ts
@@ -1,33 +1,39 @@
import assert from "node:assert/strict";
import { beforeEach, test } from "node:test";
-import { SessionMap } from "./SessionMap.ts";
-import { TabWaker, type TabControl, type WakeSwitch } from "./TabWaker.ts";
+import { SessionMap, type SessionEntry } from "./SessionMap.ts";
+import { TabWaker, type WakeAttempt, type WakeSettings, type WakeStrategy } from "./TabWaker.ts";
+
+/** Strategy recording the tabs it wakes. */
+class RecordingStrategy implements WakeStrategy {
+ readonly woken: number[] = [];
+
+ constructor(private readonly openTabs: Set) {}
+
+ async wake(entry: SessionEntry): Promise {
+ if (!this.openTabs.has(entry.tabId)) return { tabExists: false, details: "Gone." };
+ this.woken.push(entry.tabId);
+ return { tabExists: true, details: "Woken." };
+ }
+}
let sessions: SessionMap;
-let activated: number[];
let openTabs: Set;
let enabled: boolean;
+let strategy: RecordingStrategy;
let waker: TabWaker;
beforeEach(async () => {
sessions = new SessionMap({ load: async () => undefined, save: async () => {} });
- activated = [];
openTabs = new Set([7]);
enabled = true;
- const tabs: TabControl = {
- activate: async (tabId) => {
- if (!openTabs.has(tabId)) return false;
- activated.push(tabId);
- return true;
- },
- };
- const wakeSwitch: WakeSwitch = {
+ const settings: WakeSettings = {
isEnabled: async () => enabled,
setEnabled: async (value) => {
enabled = value;
},
};
- waker = new TabWaker(sessions, tabs, wakeSwitch);
+ strategy = new RecordingStrategy(openTabs);
+ waker = new TabWaker(sessions, settings, strategy);
await sessions.announce(
{ tabId: 7, windowId: 1 },
{
@@ -40,20 +46,23 @@ beforeEach(async () => {
);
});
-test("activates the tab holding the session", async () => {
- assert.equal((await waker.wake("tab")).outcome, "activated");
- assert.deepEqual(activated, [7]);
+test("wakes the tab holding the session and records the attempt", async () => {
+ assert.deepEqual(await waker.wake("tab"), { outcome: "activated", details: "Woken." });
+ assert.deepEqual(strategy.woken, [7]);
+ const { at, ...record } = waker.lastWake!;
+ assert.ok(at);
+ assert.deepEqual(record, { outcome: "activated", details: "Woken." });
});
test("does nothing while the user has turned waking off", async () => {
enabled = false;
- assert.equal((await waker.wake("tab")).outcome, "disabled");
- assert.deepEqual(activated, []);
+ assert.deepEqual(await waker.wake("tab"), { outcome: "disabled", details: "Waking is turned off." });
+ assert.deepEqual(strategy.woken, []);
});
test("reports a session no tab holds", async () => {
assert.equal((await waker.wake("other")).outcome, "unknown-session");
- assert.deepEqual(activated, []);
+ assert.deepEqual(strategy.woken, []);
});
test("reports a closed tab and forgets its session", async () => {
diff --git a/mcp/packages/browser-extension/src/TabWaker.ts b/mcp/packages/browser-extension/src/TabWaker.ts
index f3e74f1846..b07185e722 100644
--- a/mcp/packages/browser-extension/src/TabWaker.ts
+++ b/mcp/packages/browser-extension/src/TabWaker.ts
@@ -1,46 +1,69 @@
-import type { SessionMap } from "./SessionMap.ts";
+import type { WakeOutcome } from "@penpot/mcp-common";
+import type { SessionEntry, SessionMap } from "./SessionMap.ts";
import type { WakeHandler, WakeReport } from "./WakeAgentConnection.ts";
-/** Browser control over tab activation. */
-export interface TabControl {
- /**
- * Activates a tab and focuses its window.
- *
- * @returns Whether the tab still exists
- */
- activate(tabId: number): Promise;
-}
-
-/** The user's switch that pauses waking without removing the extension. */
-export interface WakeSwitch {
+/** The user's wake settings. */
+export interface WakeSettings {
+ /** Whether waking is turned on; the user can pause it without removing the extension. */
isEnabled(): Promise;
setEnabled(enabled: boolean): Promise;
}
-/**
- * Wakes the tab holding a session by activating it and focusing its window, which makes the
- * browser resume a frozen page.
- */
+/** Result of an attempt to wake a tab. */
+export interface WakeAttempt {
+ /** whether the tab still exists. */
+ tabExists: boolean;
+ /** what the strategy did, shown in the popup and the server's log. */
+ details: string;
+}
+
+/** Record of the last wake request, shown in the popup. */
+export interface WakeRecord {
+ at: string;
+ outcome: WakeOutcome;
+ details: string;
+}
+
+/** Way of waking the tab holding a session, so that the browser resumes its frozen page. */
+export interface WakeStrategy {
+ wake(entry: SessionEntry): Promise;
+}
+
+/** Wakes the tab holding a session, unless the user has turned waking off. */
export class TabWaker implements WakeHandler {
+ private last: WakeRecord | null = null;
+
constructor(
private readonly sessions: SessionMap,
- private readonly tabs: TabControl,
- private readonly wakeSwitch: WakeSwitch
+ private readonly settings: WakeSettings,
+ private readonly strategy: WakeStrategy
) {}
+ /** The last wake request, if any since the service worker started. */
+ get lastWake(): WakeRecord | null {
+ return this.last;
+ }
+
async wake(sessionId: string): Promise {
- if (!(await this.wakeSwitch.isEnabled())) {
- return { outcome: "disabled" };
+ if (!(await this.settings.isEnabled())) {
+ return this.record("disabled", "Waking is turned off.");
}
await this.sessions.ready();
const entry = this.sessions.get(sessionId);
if (!entry) {
- return { outcome: "unknown-session" };
+ return this.record("unknown-session", `No tab holds session ${sessionId}.`);
}
- if (!(await this.tabs.activate(entry.tabId))) {
+ const attempt = await this.strategy.wake(entry);
+ if (!attempt.tabExists) {
await this.sessions.removeTab(entry.tabId);
- return { outcome: "tab-closed" };
+ return this.record("tab-closed", attempt.details);
}
- return { outcome: "activated" };
+ return this.record("activated", attempt.details);
+ }
+
+ private record(outcome: WakeOutcome, details: string): WakeReport {
+ this.last = { at: new Date().toISOString(), outcome, details };
+ console.info("Penpot MCP wake:", this.last);
+ return { outcome, details };
}
}
diff --git a/mcp/packages/browser-extension/src/WakeStrategies.test.ts b/mcp/packages/browser-extension/src/WakeStrategies.test.ts
new file mode 100644
index 0000000000..171054c010
--- /dev/null
+++ b/mcp/packages/browser-extension/src/WakeStrategies.test.ts
@@ -0,0 +1,129 @@
+import assert from "node:assert/strict";
+import { beforeEach, test } from "node:test";
+import type { SessionEntry } from "./SessionMap.ts";
+import {
+ ActivateAndReturnStrategy,
+ LifecycleStrategy,
+ ResumeSignals,
+ type Focus,
+ type TabControl,
+} from "./WakeStrategies.ts";
+
+const ENTRY: SessionEntry = { tabId: 7, windowId: 1, serverUrl: "wss://penpot/mcp/ws", token: null };
+
+/** Tab control recording activations and focus changes. */
+class FakeTabs implements TabControl {
+ readonly calls: string[] = [];
+ openTabs = new Set([7]);
+ focus: Focus | null = { windowId: 2, tabId: 3, focused: true };
+
+ async activate(tabId: number): Promise {
+ if (!this.openTabs.has(tabId)) return false;
+ this.calls.push(`activate ${tabId}`);
+ this.focus = { windowId: 1, tabId, focused: true };
+ return true;
+ }
+
+ async getFocus(): Promise {
+ return this.focus;
+ }
+
+ async restoreFocus(focus: Focus): Promise {
+ this.calls.push(`restore ${focus.tabId}`);
+ }
+}
+
+function until(condition: () => boolean): Promise {
+ return new Promise((resolve) => {
+ const check = () => (condition() ? resolve() : setTimeout(check, 1));
+ check();
+ });
+}
+
+let tabs: FakeTabs;
+let resumes: ResumeSignals;
+
+beforeEach(() => {
+ tabs = new FakeTabs();
+ resumes = new ResumeSignals();
+});
+
+test("activate-and-return goes back to the previous tab once the page runs again", async () => {
+ assert.deepEqual(await new ActivateAndReturnStrategy(tabs, resumes).wake(ENTRY), {
+ tabExists: true,
+ details: "Brought the tab forward; going back to tab 3 once the page runs.",
+ });
+ assert.deepEqual(tabs.calls, ["activate 7"]);
+ resumes.notify(7);
+ await until(() => tabs.calls.length === 2);
+ assert.deepEqual(tabs.calls, ["activate 7", "restore 3"]);
+});
+
+test("activate-and-return goes back after the timeout when the page does not report", async () => {
+ await new ActivateAndReturnStrategy(tabs, resumes, 10).wake(ENTRY);
+ await until(() => tabs.calls.length === 2);
+ assert.equal(tabs.calls[1], "restore 3");
+});
+
+test("activate-and-return stays when the user has moved to another tab before going back", async () => {
+ await new ActivateAndReturnStrategy(tabs, resumes, 10).wake(ENTRY);
+ tabs.focus = { windowId: 1, tabId: 9, focused: true };
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ assert.deepEqual(tabs.calls, ["activate 7"]);
+});
+
+test("activate-and-return stays when the tab already had the focus", async () => {
+ tabs.focus = { windowId: 1, tabId: 7, focused: true };
+ await new ActivateAndReturnStrategy(tabs, resumes, 10).wake(ENTRY);
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ assert.deepEqual(tabs.calls, ["activate 7"]);
+});
+
+test("activate-and-return does not go back from a closed tab", async () => {
+ tabs.openTabs.clear();
+ assert.equal((await new ActivateAndReturnStrategy(tabs, resumes, 10).wake(ENTRY)).tabExists, false);
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ assert.deepEqual(tabs.calls, []);
+});
+
+test("the lifecycle strategy unfreezes the page without switching tabs", async () => {
+ const unfrozen: number[] = [];
+ const strategy = new LifecycleStrategy(
+ {
+ setActive: async (tabId) => {
+ unfrozen.push(tabId);
+ },
+ },
+ new ActivateAndReturnStrategy(tabs, resumes, 10)
+ );
+ assert.deepEqual(await strategy.wake(ENTRY), { tabExists: true, details: "Unfroze the page in place." });
+ assert.deepEqual(unfrozen, [7]);
+ assert.deepEqual(tabs.calls, []);
+});
+
+test("the lifecycle strategy falls back to bringing the tab forward and back when the debugger fails", async () => {
+ const strategy = new LifecycleStrategy(
+ {
+ setActive: async () => {
+ throw new Error("Could not attach: Another debugger is already attached.");
+ },
+ },
+ new ActivateAndReturnStrategy(tabs, resumes, 10)
+ );
+ assert.deepEqual(await strategy.wake(ENTRY), {
+ tabExists: true,
+ details:
+ "Debugger failed: Could not attach: Another debugger is already attached. " +
+ "Brought the tab forward; going back to tab 3 once the page runs.",
+ });
+ await until(() => tabs.calls.length === 2);
+ assert.deepEqual(tabs.calls, ["activate 7", "restore 3"]);
+});
+
+test("resume signals only answer waits for the resumed tab", async () => {
+ const seven = resumes.waitForResume(7, 1000);
+ const eight = resumes.waitForResume(8, 20);
+ resumes.notify(7);
+ assert.equal(await seven, true);
+ assert.equal(await eight, false);
+});
diff --git a/mcp/packages/browser-extension/src/WakeStrategies.ts b/mcp/packages/browser-extension/src/WakeStrategies.ts
new file mode 100644
index 0000000000..b4f1e64147
--- /dev/null
+++ b/mcp/packages/browser-extension/src/WakeStrategies.ts
@@ -0,0 +1,143 @@
+import type { SessionEntry } from "./SessionMap.ts";
+import type { WakeAttempt, WakeStrategy } from "./TabWaker.ts";
+
+/** The focused window and its active tab, to go back to after waking another tab. */
+export interface Focus {
+ windowId: number;
+ tabId: number;
+ /** whether the browser window had the focus of the desktop. */
+ focused: boolean;
+}
+
+/** Browser control over tab activation. */
+export interface TabControl {
+ /**
+ * Activates a tab and focuses its window.
+ *
+ * @returns Whether the tab still exists
+ */
+ activate(tabId: number): Promise;
+
+ /** Returns the last focused window and its active tab, if any. */
+ getFocus(): Promise;
+
+ /** Activates the tab of a focus again, and focuses its window if it had the focus. */
+ restoreFocus(focus: Focus): Promise;
+}
+
+/** Browser control over the lifecycle state of a page. */
+export interface LifecycleControl {
+ /**
+ * Makes the page of a tab active, which resumes it if frozen.
+ *
+ * @throws Error explaining why the page could not be made active
+ */
+ setActive(tabId: number): Promise;
+}
+
+const TAB_CLOSED: WakeAttempt = { tabExists: false, details: "The tab no longer exists." };
+
+/** Notice of tabs whose page runs again after being frozen or hidden. */
+export interface ResumeWatcher {
+ /**
+ * Waits for the page of a tab to report it runs again.
+ *
+ * @returns Whether the page reported it before the timeout
+ */
+ waitForResume(tabId: number, timeoutMs: number): Promise;
+}
+
+/**
+ * Brings the tab forward, which makes the browser resume its page, then goes back to the tab and
+ * window that had the focus once the page runs again. The page then stays hidden but running long
+ * enough for the task. It stays on the woken tab if the user has moved on from it meanwhile.
+ */
+export class ActivateAndReturnStrategy implements WakeStrategy {
+ /**
+ * @param tabs - The tab activation control
+ * @param resumes - The notice of resumed pages
+ * @param resumeTimeoutMs - Maximum time, in ms, to wait for the page before going back anyway
+ */
+ constructor(
+ private readonly tabs: TabControl,
+ private readonly resumes: ResumeWatcher,
+ private readonly resumeTimeoutMs = 5_000
+ ) {}
+
+ async wake(entry: SessionEntry): Promise {
+ const previous = await this.tabs.getFocus();
+ // start waiting before the activation, so that a fast resume is not missed
+ const resumed = this.resumes.waitForResume(entry.tabId, this.resumeTimeoutMs);
+ if (!(await this.tabs.activate(entry.tabId))) {
+ return TAB_CLOSED;
+ }
+ if (!previous || previous.tabId === entry.tabId) {
+ return { tabExists: true, details: "Brought the tab forward; it already had the focus." };
+ }
+ // the server waits for the plugin, not for the focus to go back
+ void resumed
+ .then(async (reported) => {
+ const current = await this.tabs.getFocus();
+ if (current?.tabId !== entry.tabId) {
+ console.info("Penpot MCP wake: the focus left the woken tab; not going back");
+ return;
+ }
+ console.info(`Penpot MCP wake: going back to tab ${previous.tabId}`, { pageReported: reported });
+ await this.tabs.restoreFocus(previous);
+ })
+ .catch(() => {});
+ return {
+ tabExists: true,
+ details: `Brought the tab forward; going back to tab ${previous.tabId} once the page runs.`,
+ };
+ }
+}
+
+/**
+ * Makes the page active through the debugger, without switching tabs, and falls back to another
+ * strategy when that fails, for instance when DevTools is open on the tab.
+ */
+export class LifecycleStrategy implements WakeStrategy {
+ constructor(
+ private readonly lifecycle: LifecycleControl,
+ private readonly fallback: WakeStrategy
+ ) {}
+
+ async wake(entry: SessionEntry): Promise {
+ try {
+ await this.lifecycle.setActive(entry.tabId);
+ return { tabExists: true, details: "Unfroze the page in place." };
+ } catch (error) {
+ const attempt = await this.fallback.wake(entry);
+ return { ...attempt, details: `Debugger failed: ${(error as Error).message} ${attempt.details}` };
+ }
+ }
+}
+
+/** Resume watcher fed by the content scripts' resume messages. */
+export class ResumeSignals implements ResumeWatcher {
+ private readonly waiters = new Map void>>();
+
+ waitForResume(tabId: number, timeoutMs: number): Promise {
+ return new Promise((resolve) => {
+ const done = (resumed: boolean) => {
+ clearTimeout(timeout);
+ this.waiters.get(tabId)?.delete(notify);
+ resolve(resumed);
+ };
+ const notify = () => done(true);
+ const timeout = setTimeout(() => done(false), timeoutMs);
+ const waiters = this.waiters.get(tabId) ?? new Set();
+ waiters.add(notify);
+ this.waiters.set(tabId, waiters);
+ });
+ }
+
+ /** Reports that the page of a tab runs again. */
+ notify(tabId: number): void {
+ for (const notify of [...(this.waiters.get(tabId) ?? [])]) {
+ notify();
+ }
+ this.waiters.delete(tabId);
+ }
+}
diff --git a/mcp/packages/browser-extension/src/background.ts b/mcp/packages/browser-extension/src/background.ts
index d60f11daad..8c56b03f32 100644
--- a/mcp/packages/browser-extension/src/background.ts
+++ b/mcp/packages/browser-extension/src/background.ts
@@ -7,19 +7,32 @@
* Chrome may stop the worker at any time, so listeners are registered synchronously at start
* and state lives in session storage.
*/
-import { ChromeDiscardControl, ChromeSessionStore, ChromeTabControl, ChromeWakeSwitch, getAgentId } from "./chrome.ts";
+import {
+ ChromeDiscardControl,
+ ChromeLifecycleControl,
+ ChromeSessionStore,
+ ChromeTabControl,
+ ChromeWakeSettings,
+ getAgentId,
+} from "./chrome.ts";
import { DiscardGuard } from "./DiscardGuard.ts";
import { parseAnnouncement, type ContentMessage } from "./messages.ts";
import { getPopupState, type PopupStateRequest } from "./PopupState.ts";
import { SessionMap } from "./SessionMap.ts";
import { TabWaker } from "./TabWaker.ts";
import { WakeAgentConnections } from "./WakeAgentConnections.ts";
+import { ActivateAndReturnStrategy, LifecycleStrategy, ResumeSignals } from "./WakeStrategies.ts";
const RESTART_ALARM = "restart-connections";
const sessions = new SessionMap(new ChromeSessionStore());
-const wakeSwitch = new ChromeWakeSwitch();
-const waker = new TabWaker(sessions, new ChromeTabControl(), wakeSwitch);
+const settings = new ChromeWakeSettings();
+const resumes = new ResumeSignals();
+const waker = new TabWaker(
+ sessions,
+ settings,
+ new LifecycleStrategy(new ChromeLifecycleControl(), new ActivateAndReturnStrategy(new ChromeTabControl(), resumes))
+);
const connections = new WakeAgentConnections(sessions, getAgentId(), (url) => new WebSocket(url), waker);
sessions.observe(new DiscardGuard(new ChromeDiscardControl()));
sessions.observe(connections);
@@ -30,8 +43,8 @@ chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateReques
}
const tab = sender.tab;
if (message?.type === "popup:get-state" && !tab) {
- void Promise.all([wakeSwitch.isEnabled(), sessions.ready()]).then(([enabled]) =>
- sendResponse(getPopupState(enabled, sessions, connections.summaries()))
+ void Promise.all([settings.isEnabled(), sessions.ready()]).then(([enabled]) =>
+ sendResponse(getPopupState(enabled, waker.lastWake, sessions, connections.summaries()))
);
// the response is sent asynchronously
return true;
@@ -39,7 +52,9 @@ chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateReques
if (tab?.id === undefined) {
return;
}
- if (message?.type === "page-hidden") {
+ if (message?.type === "page-resumed") {
+ resumes.notify(tab.id);
+ } else if (message?.type === "page-hidden") {
void sessions.removeTab(tab.id);
} else if (message?.type === "session") {
const announcement = parseAnnouncement(message.announcement);
diff --git a/mcp/packages/browser-extension/src/chrome.ts b/mcp/packages/browser-extension/src/chrome.ts
index 0bed1f1777..36c07a9b25 100644
--- a/mcp/packages/browser-extension/src/chrome.ts
+++ b/mcp/packages/browser-extension/src/chrome.ts
@@ -1,6 +1,7 @@
import type { DiscardControl } from "./DiscardGuard.ts";
import type { SessionEntry, SessionStore } from "./SessionMap.ts";
-import type { TabControl, WakeSwitch } from "./TabWaker.ts";
+import type { WakeSettings } from "./TabWaker.ts";
+import type { Focus, LifecycleControl, TabControl } from "./WakeStrategies.ts";
const SESSIONS_KEY = "sessions";
const AGENT_ID_KEY = "agentId";
@@ -61,10 +62,56 @@ export class ChromeTabControl implements TabControl {
await chrome.windows.update(tab.windowId, { focused: true });
return true;
}
+
+ async getFocus(): Promise {
+ try {
+ const window = await chrome.windows.getLastFocused();
+ const [tab] = await chrome.tabs.query({ active: true, windowId: window.id });
+ if (window.id === undefined || tab?.id === undefined) {
+ return null;
+ }
+ return { windowId: window.id, tabId: tab.id, focused: window.focused };
+ } catch {
+ return null;
+ }
+ }
+
+ async restoreFocus(focus: Focus): Promise {
+ await chrome.tabs.update(focus.tabId, { active: true });
+ if (focus.focused) {
+ await chrome.windows.update(focus.windowId, { focused: true });
+ }
+ }
}
-/** Wake switch kept in `chrome.storage.local`; waking is enabled until the user turns it off. */
-export class ChromeWakeSwitch implements WakeSwitch {
+/**
+ * Lifecycle control through the Chrome debugger API. Chrome shows a debugging notice while the
+ * extension is attached.
+ */
+export class ChromeLifecycleControl implements LifecycleControl {
+ async setActive(tabId: number): Promise {
+ if (!chrome.debugger) {
+ throw new Error("The debugger API is not available.");
+ }
+ const target = { tabId };
+ try {
+ await chrome.debugger.attach(target, "1.3");
+ } catch (error) {
+ // the tab is gone, or DevTools or another debugger is attached to it
+ throw new Error(`Could not attach: ${(error as Error).message}.`);
+ }
+ try {
+ await chrome.debugger.sendCommand(target, "Page.setWebLifecycleState", { state: "active" });
+ } catch (error) {
+ throw new Error(`Page.setWebLifecycleState failed: ${(error as Error).message}.`);
+ } finally {
+ await chrome.debugger.detach(target).catch(() => {});
+ }
+ }
+}
+
+/** Wake settings kept in `chrome.storage.local`; waking is on by default. */
+export class ChromeWakeSettings implements WakeSettings {
async isEnabled(): Promise {
const stored = await chrome.storage.local.get(WAKE_ENABLED_KEY);
return stored[WAKE_ENABLED_KEY] !== false;
diff --git a/mcp/packages/browser-extension/src/content.ts b/mcp/packages/browser-extension/src/content.ts
index 1e115228a4..60c8908752 100644
--- a/mcp/packages/browser-extension/src/content.ts
+++ b/mcp/packages/browser-extension/src/content.ts
@@ -1,3 +1,3 @@
import { PageBridge } from "./PageBridge.ts";
-new PageBridge(window, chrome.runtime).start();
+new PageBridge(window, document, chrome.runtime).start();
diff --git a/mcp/packages/browser-extension/src/manifest.test.ts b/mcp/packages/browser-extension/src/manifest.test.ts
index 5a209436c3..b2abe144f1 100644
--- a/mcp/packages/browser-extension/src/manifest.test.ts
+++ b/mcp/packages/browser-extension/src/manifest.test.ts
@@ -9,8 +9,10 @@ test("is a Manifest V3 extension whose service worker loads as a module", () =>
assert.deepEqual(manifest.background, { service_worker: "background.js", type: "module" });
});
-test("requests no permission beyond storage and alarms", () => {
- assert.deepEqual(manifest.permissions, ["storage", "alarms"]);
+test("requests storage, alarms and, to unfreeze pages in place, debugger", () => {
+ // Chrome does not allow debugger as an optional permission
+ assert.deepEqual(manifest.permissions, ["storage", "alarms", "debugger"]);
+ assert.equal(manifest.optional_permissions, undefined);
assert.equal(manifest.host_permissions, undefined);
});
diff --git a/mcp/packages/browser-extension/src/messages.ts b/mcp/packages/browser-extension/src/messages.ts
index a8a6a16206..1538b5be9b 100644
--- a/mcp/packages/browser-extension/src/messages.ts
+++ b/mcp/packages/browser-extension/src/messages.ts
@@ -5,8 +5,10 @@ import type { McpConnectionStatus, SessionAnnouncement } from "@penpot/mcp-commo
*
* - `session`: the page announced its MCP session
* - `page-hidden`: the page is being unloaded or stored in the back/forward cache
+ * - `page-resumed`: the page runs again after being frozen or hidden
*/
-export type ContentMessage = { type: "session"; announcement: SessionAnnouncement } | { type: "page-hidden" };
+export type ContentMessage =
+ { type: "session"; announcement: SessionAnnouncement } | { type: "page-hidden" } | { type: "page-resumed" };
const STATUSES: readonly McpConnectionStatus[] = ["idle", "connecting", "connected", "disconnected", "error"];
diff --git a/mcp/packages/browser-extension/src/popup.ts b/mcp/packages/browser-extension/src/popup.ts
index 2e16a4c2e1..74f84ad3a8 100644
--- a/mcp/packages/browser-extension/src/popup.ts
+++ b/mcp/packages/browser-extension/src/popup.ts
@@ -1,4 +1,4 @@
-import { ChromeWakeSwitch } from "./chrome.ts";
+import { ChromeWakeSettings } from "./chrome.ts";
import type { PopupState, PopupStateRequest } from "./PopupState.ts";
const REFRESH_INTERVAL_MS = 1_000;
@@ -11,8 +11,9 @@ const STATUS_LABELS: Record = {
closed: "Closed",
};
-const wakeSwitch = new ChromeWakeSwitch();
+const settings = new ChromeWakeSettings();
const enabledInput = document.getElementById("enabled") as HTMLInputElement;
+const lastWake = document.getElementById("last-wake") as HTMLParagraphElement;
const sessionsList = document.getElementById("sessions") as HTMLUListElement;
const connectionsList = document.getElementById("connections") as HTMLUListElement;
@@ -30,6 +31,9 @@ function renderList(list: HTMLUListElement, items: string[], placeholder: string
function render(state: PopupState): void {
enabledInput.checked = state.enabled;
+ lastWake.textContent = state.lastWake
+ ? `${new Date(state.lastWake.at).toLocaleTimeString()}: ${state.lastWake.outcome}. ${state.lastWake.details}`
+ : "None since the extension started.";
renderList(
sessionsList,
state.sessions.map((session) => `${session.sessionId} (tab ${session.tabId})`),
@@ -55,7 +59,7 @@ async function refresh(): Promise {
}
enabledInput.addEventListener("change", async () => {
- await wakeSwitch.setEnabled(enabledInput.checked);
+ await settings.setEnabled(enabledInput.checked);
await refresh();
});