From 93fdcb6b3595fb1d0a577d81c87ad0ce3a9487f6 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Wed, 21 Jan 2026 19:09:37 +0100 Subject: [PATCH] feat: first extension with active tabs keeps connection instead of being replaced - Server rejects new extension (code 4002) if existing has active tabs - Idle extensions (no tabs) can still be replaced - Extension properly enters polling mode when rejected during handshake - /extension/status now returns activeTargets count for smarter reconnection --- extension/CHANGELOG.md | 11 +++++++ extension/manifest.json | 2 +- extension/package.json | 2 +- extension/src/background.ts | 63 ++++++++++++++++++++++++++++++------- playwriter/CHANGELOG.md | 7 +++++ playwriter/package.json | 2 +- playwriter/src/cdp-relay.ts | 17 ++++++++-- 7 files changed, 87 insertions(+), 17 deletions(-) diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index 179400b..1c99c0b 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.0.69 + +### Features + +- **First extension keeps connection**: When multiple Playwriter extensions are installed, the actively-used one (with tabs) now keeps the connection. New extensions are rejected with code 4002 instead of taking over. +- **Smarter reconnection**: Extension now polls `/extension/status` for `activeTargets` count and only attempts reconnection when the other extension has no active tabs. + +### Bug Fixes + +- **Proper state handling for 4002 rejection**: Fixed issue where extension would keep retrying forever when rejected during WebSocket handshake. Now correctly enters `extension-replaced` polling state. + ## 0.0.68 ### Bug Fixes diff --git a/extension/manifest.json b/extension/manifest.json index 741152e..6924174 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Playwriter MCP", - "version": "0.0.67", + "version": "0.0.69", "description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.", "permissions": ["debugger", "tabGroups", "contextMenus", "tabs"], "host_permissions": [""], diff --git a/extension/package.json b/extension/package.json index 1ad2ba7..139a7e2 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "mcp-extension", - "version": "0.0.67", + "version": "0.0.69", "description": "Playwright MCP Browser Extension", "private": true, "repository": { diff --git a/extension/src/background.ts b/extension/src/background.ts index 2e9a4f3..6d3cf32 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -110,7 +110,12 @@ class ConnectionManager { if (settled) return settled = true clearTimeout(timeout) - reject(new Error(`WebSocket closed: ${event.reason || event.code}`)) + // Normalize 4002 rejection to consistent error message for callers to detect + if (event.code === 4002 || event.reason === 'Extension Already In Use') { + reject(new Error('Extension Already In Use')) + } else { + reject(new Error(`WebSocket closed: ${event.reason || event.code}`)) + } } }) @@ -221,9 +226,10 @@ class ConnectionManager { this.ws = null // Only one extension can connect to the relay server at a time. - // This happens when another Playwriter extension (dev/prod, or another Chrome profile) connects. + // Code 4001: Another extension replaced this one (this extension was idle) + // Code 4002: This extension tried to connect but another is actively in use if (reason === 'Extension Replaced' || code === 4001) { - logger.debug('Disconnected: another Playwriter extension connected to the relay server') + logger.debug('Disconnected: another Playwriter extension connected (this one was idle)') store.setState({ tabs: new Map(), connectionState: 'extension-replaced', @@ -232,6 +238,16 @@ class ConnectionManager { return } + if (reason === 'Extension Already In Use' || code === 4002) { + logger.debug('Rejected: another Playwriter extension is actively in use') + store.setState({ + tabs: new Map(), + connectionState: 'extension-replaced', + errorText: 'Another Playwriter extension is actively in use', + }) + return + } + // For normal disconnects, set tabs to 'connecting' state and let maintain loop handle reconnect store.setState((state) => { const newTabs = new Map(state.tabs) @@ -249,16 +265,18 @@ class ConnectionManager { continue } - // When another Playwriter extension took over, poll until slot is free + // When another Playwriter extension took over, poll until slot is free. + // Slot is free when: no extension connected, OR connected but no active tabs. if (store.getState().connectionState === 'extension-replaced') { try { const response = await fetch(`http://127.0.0.1:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) }) - const data = await response.json() - if (!data.connected) { + const data = await response.json() as { connected: boolean; activeTargets: number } + const slotAvailable = !data.connected || data.activeTargets === 0 + if (slotAvailable) { store.setState({ connectionState: 'idle', errorText: undefined }) - logger.debug('Extension slot is free, cleared error state') + logger.debug('Extension slot is free (connected:', data.connected, 'activeTargets:', data.activeTargets, '), cleared error state') } else { - logger.debug('Extension slot still taken, will retry...') + logger.debug('Extension slot still taken (activeTargets:', data.activeTargets, '), will retry...') } } catch { logger.debug('Server not available, will retry...') @@ -317,7 +335,15 @@ class ConnectionManager { } } catch (error: any) { logger.debug('Connection attempt failed:', error.message) - store.setState({ connectionState: 'idle' }) + // Check if rejected because another extension is actively in use + if (error.message === 'Extension Already In Use') { + store.setState({ + connectionState: 'extension-replaced', + errorText: 'Another Playwriter extension is actively in use', + }) + } else { + store.setState({ connectionState: 'idle' }) + } } await sleep(3000) @@ -828,13 +854,28 @@ async function connectTab(tabId: number): Promise { // Distinguish between WS connection errors and tab-specific errors // WS errors: keep in 'connecting' state, maintainLoop will retry when WS is available // Tab errors: show 'error' state (e.g., restricted page, debugger attach failed) + // Extension in use: set global 'extension-replaced' state to enter polling mode + const isExtensionInUse = + error.message === 'Extension Already In Use' || + error.message === 'Another Playwriter extension is already connected' + const isWsError = error.message === 'Server not available' || error.message === 'Connection timeout' || - error.message === 'Another Playwriter extension is already connected' || error.message.startsWith('WebSocket') - if (isWsError) { + if (isExtensionInUse) { + logger.debug(`Another extension is in use, entering polling mode`) + store.setState((state) => { + const newTabs = new Map(state.tabs) + newTabs.delete(tabId) + return { + tabs: newTabs, + connectionState: 'extension-replaced', + errorText: 'Another Playwriter extension is actively in use', + } + }) + } else if (isWsError) { logger.debug(`WS connection failed, keeping tab ${tabId} in connecting state for retry`) // Tab stays in 'connecting' state - maintainLoop will retry when WS becomes available } else { diff --git a/playwriter/CHANGELOG.md b/playwriter/CHANGELOG.md index 6500862..83bf4ae 100644 --- a/playwriter/CHANGELOG.md +++ b/playwriter/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.0.51 + +### Features + +- **First extension keeps connection**: When multiple Playwriter extensions are installed (e.g., dev and prod), the first one with active tabs now keeps the connection instead of being replaced by newer connections. Idle extensions (no tabs) can still be replaced. +- **Smarter extension slot detection**: `/extension/status` endpoint now returns `activeTargets` count, allowing extensions to know when the slot becomes available (no active tabs). + ## 0.0.50 ### Bug Fixes diff --git a/playwriter/package.json b/playwriter/package.json index 2298112..2ff8451 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -1,7 +1,7 @@ { "name": "playwriter", "description": "", - "version": "0.0.50", + "version": "0.0.51", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index 722c504..a2e9d27 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -451,7 +451,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127. }) app.get('/extension/status', (c) => { - return c.json({ connected: extensionWs !== null }) + return c.json({ + connected: extensionWs !== null, + activeTargets: connectedTargets.size + }) }) // CDP Discovery Endpoints - Standard Chrome DevTools Protocol HTTP API @@ -765,11 +768,19 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127. return { onOpen(_event, ws) { if (extensionWs) { - logger?.log(pc.yellow('Closing existing extension connection to replace with new one')) + // Only allow replacement if the existing extension has no active tabs. + // This prevents a new extension from hijacking an actively-used session. + // If the existing extension is idle (no tabs), allow the new one to take over. + if (connectedTargets.size > 0) { + logger?.log(pc.yellow(`Rejecting new extension - existing one has ${connectedTargets.size} active tab(s)`)) + ws.close(4002, 'Extension Already In Use') + return + } + + logger?.log(pc.yellow('Replacing idle extension connection (no active tabs)')) extensionWs.close(4001, 'Extension Replaced') // Clear state from the old connection to prevent leaks - connectedTargets.clear() for (const pending of extensionPendingRequests.values()) { pending.reject(new Error('Extension connection replaced')) }