/** * WebSocket bridge client for the Chrome extension MCP server. * Communicates with the Chrome extension via the office bridge server's /chrome path. */ import WebSocket from 'ws' import { SocketConnectionError } from './mcpSocketClient.js' import { localPlatformLabel, type BridgePermissionRequest, toLoggerDetail, type ChromeExtensionInfo, type ClaudeForChromeContext, type PermissionMode, type PermissionOverrides, type SocketClient, } from './types.js' /** Timeout for list_extensions response from the bridge. */ const DISCOVERY_TIMEOUT_MS = 5000 /** How long to wait for a peer_connected event when 0 extensions are found. */ const PEER_WAIT_TIMEOUT_MS = 10_000 interface PendingToolCall { resolve: (value: unknown) => void reject: (reason: Error) => void timer: NodeJS.Timeout results: unknown[] isTabsContext: boolean onPermissionRequest?: (request: BridgePermissionRequest) => Promise startTime: number toolName: string } export class BridgeClient implements SocketClient { private ws: WebSocket | null = null private connected = false private authenticated = false private connecting = false private reconnectTimer: NodeJS.Timeout | null = null private reconnectAttempts = 0 private pendingCalls = new Map() private notificationHandler: | ((notification: { method: string params?: Record }) => void) | null = null private context: ClaudeForChromeContext private permissionMode: PermissionMode = 'ask' private allowedDomains: string[] | undefined private tabsContextCollectionTimeoutMs = 2000 private toolCallTimeoutMs = 120_000 private connectionStartTime: number | null = null private connectionEstablishedTime: number | null = null /** The device_id of the selected Chrome extension for targeted routing. */ private selectedDeviceId: string | undefined /** True after first discovery attempt completes (success or timeout). */ private discoveryComplete = false /** Shared promise so concurrent callTool invocations join the same discovery. */ private discoveryPromise: Promise | null = null /** Pending discovery response from bridge. */ private pendingDiscovery: { resolve: (extensions: ChromeExtensionInfo[]) => void timeout: NodeJS.Timeout } | null = null /** The device_id we had selected before a peer_disconnected — for auto-reselect. */ private previousSelectedDeviceId: string | undefined /** Callbacks waiting for the next peer_connected event. Receives `true` on peer arrival, `false` on abort. */ private peerConnectedWaiters: Array<(arrived: boolean) => void> = [] /** The request_id of the current pending pairing broadcast. */ private pendingPairingRequestId: string | undefined /** Whether a pairing broadcast is in progress (multiple extensions, no persisted selection). */ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state flag — written in multiple places, read planned for future routing logic private pairingInProgress = false /** The deviceId from a previous persisted pairing. */ private persistedDeviceId: string | undefined /** Resolve callback for a blocking switchBrowser() call. */ private pendingSwitchResolve: | ((result: { deviceId: string; name: string } | null) => void) | null = null constructor(context: ClaudeForChromeContext) { this.context = context if (context.initialPermissionMode) { this.permissionMode = context.initialPermissionMode } } public async ensureConnected(): Promise { const { logger, serverName } = this.context logger.info( `[${serverName}] ensureConnected called, connected=${this.connected}, authenticated=${this.authenticated}, wsState=${this.ws?.readyState}`, ) if ( this.connected && this.authenticated && this.ws?.readyState === WebSocket.OPEN ) { logger.info(`[${serverName}] Already connected and authenticated`) return true } if (!this.connecting) { logger.info(`[${serverName}] Not connecting, starting connection...`) await this.connect() } else { logger.info(`[${serverName}] Already connecting, waiting...`) } // Wait for authentication with timeout return new Promise(resolve => { const timeout = setTimeout(() => { logger.info( `[${serverName}] Connection timeout, connected=${this.connected}, authenticated=${this.authenticated}`, ) resolve(false) }, 10_000) const check = () => { if (this.connected && this.authenticated) { logger.info(`[${serverName}] Connection successful`) clearTimeout(timeout) resolve(true) } else if (!this.connecting) { logger.info(`[${serverName}] No longer connecting, giving up`) clearTimeout(timeout) resolve(false) } else { setTimeout(check, 200) } } check() }) } public async callTool( name: string, args: Record, permissionOverrides?: PermissionOverrides, ): Promise { const { logger, serverName, trackEvent } = this.context if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new SocketConnectionError(`[${serverName}] Bridge not connected`) } // Lazy discovery: run on first tool call if no extension selected yet. // Use a shared promise so concurrent callers join the same discovery. if (!this.selectedDeviceId && !this.discoveryComplete) { this.discoveryPromise ??= this.discoverAndSelectExtension().finally( () => { this.discoveryPromise = null }, ) await this.discoveryPromise } // TODO: Once all extensions support pairing, throw here for multi-extension // cases where pairingInProgress is true. For now, let the bridge handle // routing — it auto-routes to a single extension or returns an error for // multiple extensions without a target_device_id. const toolUseId = crypto.randomUUID() const isTabsContext = name === 'tabs_context_mcp' const startTime = Date.now() const timeoutMs = isTabsContext ? this.tabsContextCollectionTimeoutMs : this.toolCallTimeoutMs // Track tool call start trackEvent?.('chrome_bridge_tool_call_started', { tool_name: name, tool_use_id: toolUseId, }) // Per-call overrides (from session context) take priority over // instance values (from set_permission_mode on the singleton). const effectivePermissionMode = permissionOverrides?.permissionMode ?? this.permissionMode const effectiveAllowedDomains = permissionOverrides?.allowedDomains ?? this.allowedDomains return new Promise((resolve, reject) => { const timer = setTimeout(() => { const pending = this.pendingCalls.get(toolUseId) if (pending) { this.pendingCalls.delete(toolUseId) const durationMs = Date.now() - pending.startTime if (isTabsContext && pending.results.length > 0) { // For tabs_context, resolve with collected results even on timeout trackEvent?.('chrome_bridge_tool_call_completed', { tool_name: name, tool_use_id: toolUseId, duration_ms: durationMs, }) resolve(this.mergeTabsResults(pending.results)) } else { logger.warn( `[${serverName}] Tool call timeout: ${name} (${toolUseId.slice(0, 8)}) after ${durationMs}ms, pending calls: ${this.pendingCalls.size}`, ) trackEvent?.('chrome_bridge_tool_call_timeout', { tool_name: name, tool_use_id: toolUseId, duration_ms: durationMs, timeout_ms: timeoutMs, }) reject( new SocketConnectionError( `[${serverName}] Tool call timed out: ${name}`, ), ) } } }, timeoutMs) this.pendingCalls.set(toolUseId, { resolve, reject, timer, results: [], isTabsContext, onPermissionRequest: permissionOverrides?.onPermissionRequest, startTime, toolName: name, }) const message: Record = { type: 'tool_call', tool_use_id: toolUseId, client_type: this.context.clientTypeId, tool: name, args, } // Target the selected extension for routing if (this.selectedDeviceId) { message.target_device_id = this.selectedDeviceId } // Only include permission fields when a value exists. // Priority: per-call override (from session context) > instance value (from set_permission_mode). if (effectivePermissionMode) { message.permission_mode = effectivePermissionMode } if (effectiveAllowedDomains?.length) { message.allowed_domains = effectiveAllowedDomains } if (permissionOverrides?.onPermissionRequest) { message.handle_permission_prompts = true } logger.debug( `[${serverName}] Sending tool_call: ${name} (${toolUseId.slice(0, 8)})`, ) this.ws!.send(JSON.stringify(message)) }) } public isConnected(): boolean { return ( this.connected && this.authenticated && this.ws?.readyState === WebSocket.OPEN ) } public disconnect(): void { this.cleanup() } public setNotificationHandler( handler: (notification: { method: string params?: Record }) => void, ): void { this.notificationHandler = handler } public async setPermissionMode( mode: PermissionMode, allowedDomains?: string[], ): Promise { this.permissionMode = mode this.allowedDomains = allowedDomains } // =========================================================================== // Extension discovery and selection // =========================================================================== /** * Discover connected extensions and auto-select one, or broadcast a pairing request. * Called lazily on the first tool call. */ private async discoverAndSelectExtension(): Promise { const { logger, serverName } = this.context this.persistedDeviceId ??= this.context.getPersistedDeviceId?.() let extensions = await this.queryBridgeExtensions() if (extensions.length === 0) { logger.info( `[${serverName}] No extensions connected, waiting up to ${PEER_WAIT_TIMEOUT_MS}ms for peer_connected`, ) const peerArrived = await this.waitForPeerConnected(PEER_WAIT_TIMEOUT_MS) if (peerArrived) { extensions = await this.queryBridgeExtensions() } } this.discoveryComplete = true if (extensions.length === 0) { // Still nothing — callTool will throw a clear error logger.info(`[${serverName}] No extensions found after waiting`) return } // Single extension: auto-select silently if (extensions.length === 1) { const ext = extensions[0]! if (!this.isLocalExtension(ext)) { this.context.onRemoteExtensionWarning?.(ext) } this.selectExtension(ext.deviceId) return } // Multiple extensions: check for persisted selection if (this.persistedDeviceId) { const persisted = extensions.find( e => e.deviceId === this.persistedDeviceId, ) if (persisted) { logger.info( `[${serverName}] Auto-connecting to persisted extension: ${persisted.name || persisted.deviceId.slice(0, 8)}`, ) this.selectExtension(persisted.deviceId) return } } // Multiple extensions, no valid persisted selection: broadcast and fail fast this.broadcastPairingRequest() this.pairingInProgress = true } /** * Query the bridge for connected extensions. Returns empty array on timeout. * Deduplicates by deviceId, keeping the most recent connection — the bridge * may report stale duplicates (e.g. after a service worker restart). */ private async queryBridgeExtensions(): Promise { const raw: ChromeExtensionInfo[] = await new Promise(resolve => { const timeout = setTimeout(() => { this.pendingDiscovery = null resolve([]) }, DISCOVERY_TIMEOUT_MS) this.pendingDiscovery = { resolve, timeout } this.ws?.send(JSON.stringify({ type: 'list_extensions' })) }) const byDeviceId = new Map() for (const ext of raw) { const existing = byDeviceId.get(ext.deviceId) if (!existing || ext.connectedAt > existing.connectedAt) { byDeviceId.set(ext.deviceId, ext) } } return [...byDeviceId.values()] } /** * Select an extension by device ID for per-message targeted routing. */ private selectExtension(deviceId: string): void { const { logger, serverName } = this.context this.selectedDeviceId = deviceId this.previousSelectedDeviceId = undefined logger.info( `[${serverName}] Selected Chrome extension: ${deviceId.slice(0, 8)}...`, ) } /** * Check if an extension might be on the same machine as this MCP client * by comparing OS platform. Extensions can't provide a real hostname from * the service worker sandbox, so platform is a weak heuristic. The profile * email is the primary differentiator shown in the selection dialog. */ private isLocalExtension(ext: ChromeExtensionInfo): boolean { if (!ext.osPlatform) return false return ext.osPlatform === localPlatformLabel() } /** * Returns a promise that resolves to `true` when a peer_connected event * fires, or `false` if the timeout elapses first. */ private waitForPeerConnected(timeoutMs: number): Promise { return new Promise(resolve => { const timer = setTimeout(() => { this.peerConnectedWaiters = this.peerConnectedWaiters.filter( w => w !== onPeer, ) resolve(false) }, timeoutMs) const onPeer = (arrived: boolean) => { clearTimeout(timer) resolve(arrived) } this.peerConnectedWaiters.push(onPeer) }) } /** * Broadcast a pairing request to all connected extensions. * Non-blocking — the pairing_response handler will select the extension. */ private broadcastPairingRequest(): void { const requestId = crypto.randomUUID() this.pendingPairingRequestId = requestId this.ws?.send( JSON.stringify({ type: 'pairing_request', request_id: requestId, client_type: this.context.clientTypeId, }), ) } /** * Switch to a different browser. Broadcasts a pairing request and blocks * until a response arrives or timeout (120s). Returns the paired extension * info, or null on timeout. */ public async switchBrowser(): Promise< | { deviceId: string name: string } | 'no_other_browsers' | null > { const extensions = await this.queryBridgeExtensions() const currentDeviceId = this.selectedDeviceId ?? this.previousSelectedDeviceId if ( extensions.length === 0 || (extensions.length === 1 && (!currentDeviceId || extensions[0]!.deviceId === currentDeviceId)) ) { return 'no_other_browsers' } this.previousSelectedDeviceId = this.selectedDeviceId this.selectedDeviceId = undefined this.discoveryComplete = false this.pairingInProgress = false const requestId = crypto.randomUUID() this.pendingPairingRequestId = requestId if (this.ws?.readyState !== WebSocket.OPEN) { return null } this.ws.send( JSON.stringify({ type: 'pairing_request', request_id: requestId, client_type: this.context.clientTypeId, }), ) // Resolve any previous pending switch so the caller doesn't hang forever if (this.pendingSwitchResolve) { this.pendingSwitchResolve(null) } // Block for switch_browser since user is actively engaged return new Promise(resolve => { const timer = setTimeout(() => { if (this.pendingPairingRequestId === requestId) { this.pendingPairingRequestId = undefined } this.pendingSwitchResolve = null resolve(null) }, 120_000) this.pendingSwitchResolve = result => { clearTimeout(timer) this.pendingSwitchResolve = null resolve(result) } }) } private async connect(): Promise { const { logger, serverName, bridgeConfig, trackEvent } = this.context if (!bridgeConfig) { logger.error(`[${serverName}] No bridge config provided`) return } if (this.connecting) { return } this.connecting = true this.authenticated = false this.connectionStartTime = Date.now() this.closeSocket() // Get user ID for the connection path let userId: string let token: string | undefined if (bridgeConfig.devUserId) { userId = bridgeConfig.devUserId logger.debug(`[${serverName}] Using dev user ID for bridge connection`) } else { logger.debug(`[${serverName}] Fetching user ID for bridge connection`) const fetchedUserId = await bridgeConfig.getUserId() if (!fetchedUserId) { const durationMs = Date.now() - this.connectionStartTime logger.error( `[${serverName}] No user ID available after ${durationMs}ms`, ) trackEvent?.('chrome_bridge_connection_failed', { duration_ms: durationMs, error_type: 'no_user_id', reconnect_attempt: this.reconnectAttempts, }) this.connecting = false this.context.onAuthenticationError?.() return } userId = fetchedUserId logger.debug(`[${serverName}] Fetching OAuth token for bridge connection`) token = await bridgeConfig.getOAuthToken() if (!token) { const durationMs = Date.now() - this.connectionStartTime logger.error( `[${serverName}] No OAuth token available after ${durationMs}ms`, ) trackEvent?.('chrome_bridge_connection_failed', { duration_ms: durationMs, error_type: 'no_oauth_token', reconnect_attempt: this.reconnectAttempts, }) this.connecting = false this.context.onAuthenticationError?.() return } } // Connect to user-specific endpoint: /chrome/ const wsUrl = `${bridgeConfig.url}/chrome/${userId}` logger.info(`[${serverName}] Connecting to bridge: ${wsUrl}`) // Track connection started trackEvent?.('chrome_bridge_connection_started', { bridge_url: wsUrl, }) try { this.ws = new WebSocket(wsUrl) } catch (error) { const durationMs = Date.now() - this.connectionStartTime logger.error( `[${serverName}] Failed to create WebSocket after ${durationMs}ms:`, toLoggerDetail(error), ) trackEvent?.('chrome_bridge_connection_failed', { duration_ms: durationMs, error_type: 'websocket_error', reconnect_attempt: this.reconnectAttempts, }) this.connecting = false this.scheduleReconnect() return } this.ws.on('open', () => { logger.info( `[${serverName}] WebSocket connected, sending connect message`, ) // First message must be connect (same format as office path) const connectMessage: Record = { type: 'connect', client_type: this.context.clientTypeId, } if (bridgeConfig.devUserId) { connectMessage.dev_user_id = bridgeConfig.devUserId } else { connectMessage.oauth_token = token } this.ws?.send(JSON.stringify(connectMessage)) }) this.ws.on('message', (data: WebSocket.Data) => { try { const message = JSON.parse(data.toString()) as Record logger.debug( `[${serverName}] Bridge received: ${JSON.stringify(message)}`, ) this.handleMessage(message) } catch (error) { logger.error( `[${serverName}] Failed to parse bridge message:`, toLoggerDetail(error), ) } }) this.ws.on('close', (code: number) => { const durationSinceConnect = this.connectionEstablishedTime ? Date.now() - this.connectionEstablishedTime : 0 logger.info( `[${serverName}] Bridge connection closed (code: ${code}, duration: ${durationSinceConnect}ms)`, ) trackEvent?.('chrome_bridge_disconnected', { close_code: code, duration_since_connect_ms: durationSinceConnect, reconnect_attempt: this.reconnectAttempts + 1, }) this.connected = false this.authenticated = false this.connecting = false this.connectionEstablishedTime = null this.scheduleReconnect() }) this.ws.on('error', (error: Error) => { const durationMs = this.connectionStartTime ? Date.now() - this.connectionStartTime : 0 logger.error( `[${serverName}] Bridge WebSocket error after ${durationMs}ms: ${error.message}`, ) trackEvent?.('chrome_bridge_connection_failed', { duration_ms: durationMs, error_type: 'websocket_error', reconnect_attempt: this.reconnectAttempts, }) this.connected = false this.authenticated = false this.connecting = false }) } private handleMessage(message: Record): void { const { logger, serverName, trackEvent } = this.context switch (message.type) { case 'paired': { const durationMs = this.connectionStartTime ? Date.now() - this.connectionStartTime : 0 logger.info( `[${serverName}] Paired with Chrome extension (duration: ${durationMs}ms)`, ) this.connected = true this.authenticated = true this.connecting = false this.reconnectAttempts = 0 this.connectionEstablishedTime = Date.now() trackEvent?.('chrome_bridge_connection_succeeded', { duration_ms: durationMs, status: 'paired', }) break } case 'waiting': { const durationMs = this.connectionStartTime ? Date.now() - this.connectionStartTime : 0 logger.info( `[${serverName}] Waiting for Chrome extension to connect (duration: ${durationMs}ms)`, ) this.connected = true this.authenticated = true this.connecting = false this.reconnectAttempts = 0 this.connectionEstablishedTime = Date.now() trackEvent?.('chrome_bridge_connection_succeeded', { duration_ms: durationMs, status: 'waiting', }) break } case 'peer_connected': logger.info(`[${serverName}] Chrome extension connected to bridge`) trackEvent?.('chrome_bridge_peer_connected', null) // If no extension selected, mark discovery as needed (next tool call will discover) if (!this.selectedDeviceId) { this.discoveryComplete = false } // Auto-reselect if the previously selected extension reconnected (e.g., service worker restart) if ( this.previousSelectedDeviceId && message.deviceId === this.previousSelectedDeviceId && !this.pendingSwitchResolve ) { logger.info( `[${serverName}] Previously selected extension reconnected, auto-reselecting`, ) this.selectExtension(this.previousSelectedDeviceId) this.previousSelectedDeviceId = undefined } if (this.peerConnectedWaiters.length > 0) { const waiters = this.peerConnectedWaiters this.peerConnectedWaiters = [] for (const waiter of waiters) { waiter(true) } } break case 'peer_disconnected': logger.info(`[${serverName}] Chrome extension disconnected from bridge`) trackEvent?.('chrome_bridge_peer_disconnected', null) // If the selected extension disconnected, clear selection for re-discovery if (message.deviceId && message.deviceId === this.selectedDeviceId) { logger.info( `[${serverName}] Selected extension disconnected, clearing selection`, ) this.previousSelectedDeviceId = this.selectedDeviceId this.selectedDeviceId = undefined this.discoveryComplete = false } break case 'extensions_list': // Response to list_extensions — resolve pending discovery if (this.pendingDiscovery) { clearTimeout(this.pendingDiscovery.timeout) this.pendingDiscovery.resolve( (message.extensions as ChromeExtensionInfo[]) ?? [], ) this.pendingDiscovery = null } break case 'pairing_response': { const requestId = message.request_id as string const responseDeviceId = message.device_id as string const responseName = message.name as string if ( this.pendingPairingRequestId === requestId && responseDeviceId && responseName ) { this.pendingPairingRequestId = undefined this.pairingInProgress = false this.selectExtension(responseDeviceId) this.context.onExtensionPaired?.(responseDeviceId, responseName) logger.info( `[${serverName}] Paired with "${responseName}" (${responseDeviceId.slice(0, 8)})`, ) if (this.pendingSwitchResolve) { this.pendingSwitchResolve({ deviceId: responseDeviceId, name: responseName, }) this.pendingSwitchResolve = null } } break } case 'ping': this.ws?.send(JSON.stringify({ type: 'pong' })) break case 'pong': // Response to our keepalive, nothing to do break case 'tool_result': this.handleToolResult(message) break case 'permission_request': void this.handlePermissionRequest(message) break case 'notification': if (this.notificationHandler) { this.notificationHandler({ method: message.method as string, params: message.params as Record | undefined, }) } break case 'error': logger.warn(`[${serverName}] Bridge error: ${message.error}`) // If we had a selected extension, the error may indicate it's gone // (e.g., extension disconnected between list and select). Clear state // so the next tool call re-discovers. if (this.selectedDeviceId) { this.selectedDeviceId = undefined this.discoveryComplete = false } break default: logger.warn( `[${serverName}] Unrecognized bridge message type: ${message.type}`, ) } } private async handlePermissionRequest( message: Record, ): Promise { const { logger, serverName } = this.context const toolUseId = message.tool_use_id as string const requestId = message.request_id as string if (!toolUseId || !requestId) { logger.warn( `[${serverName}] permission_request missing tool_use_id or request_id`, ) return } const pending = this.pendingCalls.get(toolUseId) if (!pending?.onPermissionRequest) { // Don't auto-deny — the bridge broadcasts permission_request to all // connected MCP clients, and only the client that made the tool call // has the pending entry. Auto-denying here would race with the correct // client's handler when multiple Desktop instances are connected. logger.debug( `[${serverName}] Ignoring permission_request for unknown tool_use_id ${toolUseId.slice(0, 8)} (not our call)`, ) return } const request: BridgePermissionRequest = { toolUseId, requestId, toolType: (message.tool_type as string) ?? 'unknown', url: (message.url as string) ?? '', actionData: message.action_data as Record | undefined, } try { const allowed = await pending.onPermissionRequest(request) this.sendPermissionResponse(requestId, allowed) } catch (error) { logger.error( `[${serverName}] Error handling permission request:`, toLoggerDetail(error), ) this.sendPermissionResponse(requestId, false) } } private sendPermissionResponse(requestId: string, allowed: boolean): void { if (this.ws?.readyState === WebSocket.OPEN) { const message: Record = { type: 'permission_response', request_id: requestId, allowed, } if (this.selectedDeviceId) { message.target_device_id = this.selectedDeviceId } this.ws.send(JSON.stringify(message)) } } private handleToolResult(message: Record): void { const { logger, serverName, trackEvent } = this.context const toolUseId = message.tool_use_id as string if (!toolUseId) { logger.warn(`[${serverName}] Received tool_result without tool_use_id`) return } const pending = this.pendingCalls.get(toolUseId) if (!pending) { logger.debug( `[${serverName}] Received tool_result for unknown call: ${toolUseId.slice(0, 8)}`, ) return } const durationMs = Date.now() - pending.startTime // Normalize bridge response format to match socket client format. // Bridge sends: { type, tool_use_id, content: [...], is_error?: boolean } // Socket sends: { result: { content: [...] } } or { error: { content: [...] } } const normalized = this.normalizeBridgeResponse(message) const isError = Boolean(message.is_error) || 'error' in normalized if (pending.isTabsContext && !this.selectedDeviceId) { // No extension selected: collect results from all extensions (pre-selection / backward compat) pending.results.push(normalized) // Don't resolve yet — let the timer handle collection } else { // For other tools, resolve on first result clearTimeout(pending.timer) this.pendingCalls.delete(toolUseId) if (isError) { // Extract error message for telemetry const errorContent = (normalized as { error?: { content?: unknown[] } }) .error?.content let errorMessage = 'Unknown error' if (Array.isArray(errorContent)) { const textItem = errorContent.find( item => typeof item === 'object' && item !== null && 'text' in item, ) as { text?: string } | undefined if (textItem?.text) { errorMessage = textItem.text.slice(0, 200) } } logger.warn( `[${serverName}] Tool call error: ${pending.toolName} (${toolUseId.slice(0, 8)}) after ${durationMs}ms`, ) trackEvent?.('chrome_bridge_tool_call_error', { tool_name: pending.toolName, tool_use_id: toolUseId, duration_ms: durationMs, error_message: errorMessage, }) } else { logger.debug( `[${serverName}] Tool call completed: ${pending.toolName} (${toolUseId.slice(0, 8)}) in ${durationMs}ms`, ) trackEvent?.('chrome_bridge_tool_call_completed', { tool_name: pending.toolName, tool_use_id: toolUseId, duration_ms: durationMs, }) } pending.resolve(normalized) } } private normalizeBridgeResponse( message: Record, ): Record { // Already has result/error wrapper (socket format) — pass through if (message.result || message.error) { return message } // Bridge format has content at top level — wrap it if (message.content) { if (message.is_error) { return { error: { content: message.content } } } return { result: { content: message.content } } } return message } private mergeTabsResults(results: unknown[]): unknown { const mergedTabs: unknown[] = [] for (const result of results) { const msg = result as Record const resultData = msg.result as | { content?: Array<{ type: string; text?: string }> } | undefined const content = resultData?.content if (!content || !Array.isArray(content)) continue for (const item of content) { if (item.type === 'text' && item.text) { try { const parsed = JSON.parse(item.text) if (Array.isArray(parsed)) { mergedTabs.push(...parsed) } else if ( parsed?.availableTabs && Array.isArray(parsed.availableTabs) ) { mergedTabs.push(...parsed.availableTabs) } } catch { // Not JSON, skip } } } } if (mergedTabs.length > 0) { const tabListText = mergedTabs .map(t => { const tab = t as { tabId: number; title: string; url: string } return ` \u2022 tabId ${tab.tabId}: "${tab.title}" (${tab.url})` }) .join('\n') return { result: { content: [ { type: 'text', text: JSON.stringify({ availableTabs: mergedTabs }), }, { type: 'text', text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`, }, ], }, } } // Return first result as fallback return results[0] } private scheduleReconnect(): void { const { logger, serverName, trackEvent } = this.context if (this.reconnectTimer) return this.reconnectAttempts++ if (this.reconnectAttempts > 100) { logger.warn( `[${serverName}] Giving up bridge reconnection after 100 attempts`, ) trackEvent?.('chrome_bridge_reconnect_exhausted', { total_attempts: 100, }) this.reconnectAttempts = 0 return } const delay = Math.min(2000 * 1.5 ** (this.reconnectAttempts - 1), 30_000) if (this.reconnectAttempts <= 10 || this.reconnectAttempts % 10 === 0) { logger.info( `[${serverName}] Bridge reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`, ) } this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null void this.connect() }, delay) } private closeSocket(): void { if (this.ws) { this.ws.removeAllListeners() this.ws.close() this.ws = null } this.connected = false this.authenticated = false // Clear extension selection state so reconnections start fresh this.selectedDeviceId = undefined this.discoveryComplete = false this.pendingPairingRequestId = undefined this.pairingInProgress = false if (this.pendingSwitchResolve) { this.pendingSwitchResolve(null) this.pendingSwitchResolve = null } if (this.pendingDiscovery) { clearTimeout(this.pendingDiscovery.timeout) this.pendingDiscovery.resolve([]) this.pendingDiscovery = null } // Unblock any in-progress waitForPeerConnected so it doesn't hang until its timeout if (this.peerConnectedWaiters.length > 0) { const waiters = this.peerConnectedWaiters this.peerConnectedWaiters = [] for (const waiter of waiters) { waiter(false) } } } private cleanup(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } // Reject all pending calls for (const [id, pending] of this.pendingCalls) { clearTimeout(pending.timer) pending.reject(new SocketConnectionError('Bridge client disconnected')) this.pendingCalls.delete(id) } this.closeSocket() this.reconnectAttempts = 0 } } export function createBridgeClient( context: ClaudeForChromeContext, ): BridgeClient { return new BridgeClient(context) }