UNPKG

@mastra/core

Version:

Mastra is a framework for building AI-powered applications and agents with a modern TypeScript stack.

1,376 lines (1,365 loc) • 48 kB
'use strict'; var chunkACQ5CVFF_cjs = require('../chunk-ACQ5CVFF.cjs'); var chunk7OCF5TOO_cjs = require('../chunk-7OCF5TOO.cjs'); var chunkFCQNDFEW_cjs = require('../chunk-FCQNDFEW.cjs'); var chunk7GW2TQXP_cjs = require('../chunk-7GW2TQXP.cjs'); var fs = require('fs'); var path = require('path'); var events = require('events'); // src/browser/errors.ts var RETRYABLE_CODES = /* @__PURE__ */ new Set(["timeout", "element_blocked"]); function createError(code, message, hint) { return { success: false, code, message, recoveryHint: hint, canRetry: RETRYABLE_CODES.has(code) }; } // src/browser/processor.ts var REMINDER_TYPE = "browser-context"; var BrowserContextProcessor = class { id = "browser-context"; processInput(args) { const ctx = args.requestContext?.get("browser"); if (!ctx) return args.messageList; const lines = [`You have access to a browser (${ctx.provider}).`]; if (ctx.headless === false) { lines.push("The browser is running in visible mode (not headless)."); } if (ctx.sessionId) { lines.push(`Session ID: ${ctx.sessionId}`); } if (ctx.providerType === "cli" && ctx.cdpUrl) { lines.push(`CDP WebSocket URL: ${ctx.cdpUrl}`); } const systemMessages = [...args.systemMessages, { role: "system", content: lines.join(" ") }]; return { messages: args.messages, systemMessages }; } async processInputStep(args) { if (args.stepNumber !== 0) return; const ctx = args.requestContext?.get("browser"); if (!ctx) return; const parts = []; if (ctx.currentUrl) { parts.push(`Current URL: ${ctx.currentUrl}`); } if (ctx.pageTitle) { parts.push(`Page title: ${ctx.pageTitle}`); } if (parts.length === 0) return; const reminderText = parts.join(" | "); const existingMessages = args.messageList.get.all.db(); if (hasTrailingBrowserReminder(existingMessages, ctx.currentUrl, ctx.pageTitle)) { return; } await args.sendSignal?.({ type: "system-reminder", contents: reminderText, attributes: { type: REMINDER_TYPE }, metadata: { url: ctx.currentUrl, title: ctx.pageTitle } }); return args.messageList; } }; function hasTrailingBrowserReminder(messages, url, title) { const msg = messages[messages.length - 1]; if (!msg || msg.role !== "user" && msg.role !== "signal") return false; const metadata = msg.content.metadata; if (typeof metadata !== "object" || metadata === null) { return false; } const signal = metadata.signal; const reminder = signal ? { type: signal.attributes?.type, url: signal.metadata?.url, title: signal.metadata?.title } : "systemReminder" in metadata ? metadata.systemReminder : metadata; return reminder?.type === REMINDER_TYPE && reminder.url === url && reminder.title === title; } // src/browser/thread-manager.ts var DEFAULT_THREAD_ID = "__default__"; var ThreadManager = class { scope; logger; sessions = /* @__PURE__ */ new Map(); activeThreadId = DEFAULT_THREAD_ID; /** Preserved browser state that survives session clears (for browser restore) */ savedBrowserStates = /* @__PURE__ */ new Map(); /** Shared manager instance (used for 'shared' scope) */ sharedManager = null; /** Map of thread ID to dedicated manager instance (for 'thread' scope) */ threadManagers = /* @__PURE__ */ new Map(); onSessionCreated; onSessionDestroyed; constructor(config) { this.scope = config.scope; this.logger = config.logger; this.onSessionCreated = config.onSessionCreated; this.onSessionDestroyed = config.onSessionDestroyed; } /** * Get the current browser scope mode. */ getScope() { return this.scope; } /** * Get the currently active thread ID. */ getActiveThreadId() { return this.activeThreadId; } /** * Set the shared manager instance (called after browser launch). */ setSharedManager(manager) { this.sharedManager = manager; } /** * Clear the shared manager instance (called when browser disconnects). */ clearSharedManager() { this.sharedManager = null; } /** * Get the manager for an existing thread session without creating a new one. * * For 'thread' scope: Returns the thread-specific manager, or null if no session exists. * For 'shared' scope: Returns the shared manager (all threads use the same instance). * * @param threadId - Thread identifier (defaults to DEFAULT_THREAD_ID) * @returns The manager for the thread, or null if not found (thread scope only) */ getExistingManagerForThread(threadId) { const effectiveThreadId = threadId ?? DEFAULT_THREAD_ID; if (this.scope === "thread") { return this.threadManagers.get(effectiveThreadId) ?? null; } return this.sharedManager; } /** * Check if any thread managers are still running (for 'thread' scope). */ hasActiveThreadManagers() { return this.threadManagers.size > 0; } /** * Clear all session tracking without closing managers. * Used when browsers have been externally closed and we just need to reset state. */ clearAllSessions() { this.threadManagers.clear(); this.sessions.clear(); this.activeThreadId = DEFAULT_THREAD_ID; } /** * Get a session by thread ID. */ getSession(threadId) { return this.sessions.get(threadId); } /** * Check if a session exists for a thread. */ hasSession(threadId) { return this.sessions.has(threadId); } /** * List all active sessions. */ listSessions() { return Array.from(this.sessions.values()); } /** * Get the number of active sessions. */ getSessionCount() { return this.sessions.size; } /** * Get or create a session for a thread, and return the browser manager for that thread. * * For 'shared' scope, returns the shared manager. * For 'thread' scope, creates/returns a dedicated manager for the thread. * * @param threadId - Thread identifier (uses DEFAULT_THREAD_ID if not provided) * @returns The browser manager for the thread */ async getManagerForThread(threadId) { const effectiveThreadId = threadId ?? DEFAULT_THREAD_ID; if (this.scope === "shared") { return this.getSharedManager(); } let session = this.sessions.get(effectiveThreadId); if (!session) { session = await this.createSession(effectiveThreadId); this.sessions.set(effectiveThreadId, session); this.logger?.debug?.(`Created thread session: ${effectiveThreadId}`); this.onSessionCreated?.(session); } this.activeThreadId = effectiveThreadId; return this.getManagerForSession(session); } /** * Destroy a specific thread's session. * * @param threadId - Thread identifier */ async destroySession(threadId) { const session = this.sessions.get(threadId); if (!session) { return; } await this.doDestroySession(session); this.threadManagers.delete(threadId); this.sessions.delete(threadId); this.logger?.debug?.(`Destroyed thread session: ${threadId}`); this.onSessionDestroyed?.(threadId); if (this.activeThreadId === threadId) { this.activeThreadId = DEFAULT_THREAD_ID; } } /** * Destroy all thread sessions. */ async destroyAllSessions() { const threadIds = Array.from(this.sessions.keys()); for (const threadId of threadIds) { await this.destroySession(threadId); } this.activeThreadId = DEFAULT_THREAD_ID; } /** * Update the browser state for a thread session. * Also saves to persistent storage so state survives session clears. */ updateBrowserState(threadId, state) { const filteredTabs = state.tabs.filter((tab) => tab.url && tab.url !== "about:blank"); if (filteredTabs.length === 0) { return; } const filteredState = { tabs: filteredTabs, activeTabIndex: Math.max(0, Math.min(state.activeTabIndex, filteredTabs.length - 1)) }; const session = this.sessions.get(threadId); if (session) { session.browserState = filteredState; } this.savedBrowserStates.set(threadId, filteredState); } /** * Get the saved browser state for a thread (survives session clears). */ getSavedBrowserState(threadId) { const session = this.sessions.get(threadId); if (session?.browserState) { return session.browserState; } return this.savedBrowserStates.get(threadId); } /** * Clear a specific thread's session without closing the browser. * Used when a thread's browser has been externally closed. * Preserves the browser state for potential restoration. * * @param threadId - The thread ID to clear */ clearSession(threadId) { const session = this.sessions.get(threadId); if (session?.browserState) { this.savedBrowserStates.set(threadId, session.browserState); } this.threadManagers.delete(threadId); this.sessions.delete(threadId); if (this.activeThreadId === threadId) { this.activeThreadId = DEFAULT_THREAD_ID; } } // --------------------------------------------------------------------------- // Abstract methods to be implemented by subclasses // --------------------------------------------------------------------------- /** * Get the shared browser manager (used for 'shared' scope and default thread). * @throws Error if shared manager is not initialized */ getSharedManager() { if (!this.sharedManager) { throw new Error("Browser not launched"); } return this.sharedManager; } }; // src/browser/browser.ts var CHROME_LOCK_FILES = ["SingletonLock", "SingletonSocket", "SingletonCookie", "chrome.pid", "RunningChromeVersion"]; function cleanupProfileLockFiles(profilePath, logger) { if (!profilePath || !fs.existsSync(profilePath)) { return; } try { const entries = fs.readdirSync(profilePath); for (const entry of entries) { if (CHROME_LOCK_FILES.includes(entry)) { const fullPath = path.join(profilePath, entry); try { const stat = fs.lstatSync(fullPath); if (stat.isFile() || stat.isSymbolicLink()) { fs.unlinkSync(fullPath); logger?.debug?.(`Removed stale lock file: ${fullPath}`); } } catch (err) { logger?.warn?.(`Failed to remove lock file ${fullPath}: ${err}`); } } } } catch (err) { logger?.warn?.(`Failed to clean up profile lock files in ${profilePath}: ${err}`); } } function killProcessGroup(pid, logger) { if (pid == null) return; try { process.kill(-pid, "SIGKILL"); logger?.debug?.(`Killed process group for PID ${pid}`); } catch (err) { const code = err.code; if (code !== "ESRCH") { logger?.warn?.(`Failed to kill process group ${pid}: ${code ?? err}`); } } } var MastraBrowser = class _MastraBrowser extends chunkFCQNDFEW_cjs.MastraBase { /** * Provider type for runtime enforcement. * - 'sdk': SDK providers (AgentBrowser, StagehandBrowser) — use with Agent.browser * - 'cli': CLI providers (BrowserViewer) — use with Workspace.browser * Defaults to 'sdk' for backward compatibility with existing providers. */ providerType = "sdk"; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- /** Current lifecycle status */ status = "pending"; /** Error message when status is 'error' */ error; /** * Whether the browser is running in headless mode. * Returns true by default if not explicitly configured. */ get headless() { return this.config.headless ?? true; } /** Last known browser state before browser was closed (for restore on relaunch) */ lastBrowserState; /** * Shared manager instance for 'shared' scope mode. * Type varies by provider (e.g., BrowserManager for agent-browser, Stagehand for stagehand). * Providers should cast this to their specific type when accessing. */ sharedManager = null; /** Configuration */ config; /** * Thread manager for handling thread-scoped browser sessions. * Set by subclasses that support thread isolation. */ threadManager; /** * Current thread ID for browser operations. * Used by thread isolation to route operations to the correct session. */ currentThreadId = DEFAULT_THREAD_ID; // --------------------------------------------------------------------------- // Screencast State // --------------------------------------------------------------------------- /** Default key for shared scope screencast streams */ static SHARED_STREAM_KEY = "__shared__"; /** Active screencast streams per thread (for triggering reconnects on tab changes) */ activeScreencastStreams = /* @__PURE__ */ new Map(); // --------------------------------------------------------------------------- // Process ID Tracking (for orphaned process cleanup) // --------------------------------------------------------------------------- /** * PID of the shared browser process. * Set by providers after launch so the base class can kill the process group * (GPU, renderer, crashpad, etc.) when the browser disconnects or closes. */ sharedBrowserPid; /** * PIDs of per-thread browser processes. * Set by providers after creating a thread session. */ threadBrowserPids = /* @__PURE__ */ new Map(); /** * Get the stream key for a thread (or shared key for shared scope). * @param threadId - Optional thread ID * @returns The stream key to use for the screencast streams map */ getStreamKey(threadId) { return threadId || _MastraBrowser.SHARED_STREAM_KEY; } /** * Reconnect the active screencast for a specific thread. * Called internally when tabs are switched or closed. */ async reconnectScreencastForThread(threadId, reason) { const streamKey = this.getStreamKey(threadId); const stream = this.activeScreencastStreams.get(streamKey); if (!stream || !stream.isActive()) { return; } if (!this.isBrowserRunning()) { this.logger.debug?.("Skipping screencast reconnect - browser not running"); return; } const scope = this.getScope(); if (scope === "thread" && threadId && !this.threadManager?.getExistingManagerForThread(threadId)) { this.logger.debug?.(`Skipping screencast reconnect - no session for thread ${threadId}`); return; } this.logger.debug?.(`Reconnecting screencast: ${reason}`); try { await new Promise((resolve) => setTimeout(resolve, 150)); await stream.reconnect(); const activePage = await this.getActivePage(threadId); if (activePage) { const url = activePage.url(); if (url) { stream.emitUrl(url); } } } catch (error) { this.logger.debug?.("Screencast reconnect failed", error); } } /** * Update the browser state in the thread session. * Called on navigation, tab open/close to keep state fresh. */ updateSessionBrowserState(threadId) { try { const effectiveThreadId = threadId ?? this.getCurrentThread() ?? DEFAULT_THREAD_ID; const state = this.getBrowserStateForThread(effectiveThreadId); if (state) { this.threadManager?.updateBrowserState(effectiveThreadId, state); } } catch { } } // --------------------------------------------------------------------------- // Lifecycle Promise Tracking (prevents race conditions) // --------------------------------------------------------------------------- _launchPromise; _closePromise; // --------------------------------------------------------------------------- // Constructor // --------------------------------------------------------------------------- constructor(config = {}) { super({ name: "MastraBrowser", component: chunk7GW2TQXP_cjs.RegisteredLogger.BROWSER }); this.config = config; const scope = config.scope; if (config.cdpUrl && scope === "thread") { throw new Error( `Invalid browser configuration: "cdpUrl" and "scope: 'thread'" cannot be used together. \u2022 cdpUrl connects to a single existing browser instance (all threads share it) \u2022 scope: "thread" requires spawning separate browser instances per thread To fix this, either: 1. Remove cdpUrl to let the provider spawn separate browser instances (supports thread isolation) 2. Use scope: "shared" when connecting via cdpUrl (all threads share one browser)` ); } if (config.cdpUrl && (config.profile || config.executablePath)) { const conflicting = [config.profile && "profile", config.executablePath && "executablePath"].filter(Boolean).join(" and "); throw new Error( `Invalid browser configuration: "cdpUrl" cannot be used with ${conflicting}. \u2022 cdpUrl connects to an existing browser (which has its own profile and executable) \u2022 profile and executablePath are launch-time options for spawning a new browser To fix this, either: 1. Remove cdpUrl to launch a new browser with your profile/executable 2. Remove profile/executablePath to connect to the existing browser via CDP` ); } } /** * Get the CDP WebSocket URL for connecting to this browser. * CLI providers (BrowserViewer) implement this to expose the URL for CLI tools. * SDK providers typically return null as they manage their own CDP connections. * * @param _threadId - Thread identifier (for thread-scoped browsers) * @returns The CDP WebSocket URL (e.g., ws://127.0.0.1:9222/devtools/browser/...) */ getCdpUrl(_threadId) { return null; } /** * Launch the browser. * Race-condition-safe - handles concurrent calls, status management, and lifecycle hooks. * @param _threadId - Thread identifier (for thread-scoped browsers, launches a browser for that thread) */ async launch(threadId) { if (threadId !== void 0) { this.setCurrentThread(threadId); } if (this.status === "ready") { return; } if (this.status === "launching" && this._launchPromise) { return this._launchPromise; } if (this.status === "closing" || this.status === "closed") { throw new Error(`Cannot launch browser in '${this.status}' state`); } this.status = "launching"; this.error = void 0; this._launchPromise = (async () => { try { await this.doLaunch(); this.status = "ready"; if (this.config.onLaunch) { await this.config.onLaunch({ browser: this }); } this.notifyBrowserReady(); } catch (err) { this.status = "error"; this.error = err instanceof Error ? err.message : String(err); throw err; } finally { this._launchPromise = void 0; } })(); return this._launchPromise; } /** * Close the browser. * Race-condition-safe - handles concurrent calls, status management, and lifecycle hooks. */ async close() { if (this.status === "closed") { return; } if (this.status === "closing" && this._closePromise) { return this._closePromise; } if (this.status === "launching" && this._launchPromise) { try { await this._launchPromise; } catch { this.status = "closed"; return; } } if (this.config.onClose && this.status === "ready") { await this.config.onClose({ browser: this }); } const currentState = await this.getBrowserState(); if (currentState && currentState.tabs.length > 0) { this.lastBrowserState = currentState; } this.status = "closing"; this._closePromise = (async () => { try { await this.doClose(); this.status = "closed"; this.notifyBrowserClosed(); if (this.config.profile) { cleanupProfileLockFiles(this.config.profile, this.logger); } } catch (err) { this.status = "error"; this.error = err instanceof Error ? err.message : String(err); throw err; } finally { this._closePromise = void 0; killProcessGroup(this.sharedBrowserPid, this.logger); this.sharedBrowserPid = void 0; for (const [, pid] of this.threadBrowserPids) { killProcessGroup(pid, this.logger); } this.threadBrowserPids.clear(); } })(); return this._closePromise; } /** * Connect to an external browser via CDP URL for screencast. * * Use this when an agent is using their own external CDP (e.g., browser-use cloud). * Connects Playwright to the external browser to enable screencast without launching * our own browser. * * Override this in subclasses that support external CDP connections. * The base implementation throws an error. * * @param cdpUrl - The external CDP WebSocket URL (wss://... or ws://...) * @param threadId - Thread ID to associate the session with */ async connectToExternalCdp(_cdpUrl, _threadId) { throw new Error(`${this.provider} does not support connecting to external CDP`); } /** * Ensure the browser is ready, launching if needed. * If browser was previously closed, it will be re-launched. */ async ensureReady() { if (this.status === "ready") { const stillAlive = await this.checkBrowserAlive(); if (stillAlive) { return; } this.status = "closed"; } if (this.status === "pending" || this.status === "error" || this.status === "closed") { if (this.status === "closed") { this.status = "pending"; } await this.launch(); return; } if (this.status === "launching") { await this._launchPromise; return; } if (this.status === "closing") { await this._closePromise; this.status = "pending"; await this.launch(); return; } throw new Error(`Browser is ${this.status} and cannot be used`); } /** * Check if the browser is still alive. * Override in subclass to detect externally closed browsers. * @returns true if browser is alive, false if it was externally closed */ async checkBrowserAlive() { return true; } /** * Check if the browser is currently running. * @param _threadId - Thread identifier (for thread-scoped browsers) */ isBrowserRunning(_threadId) { return this.status === "ready"; } // --------------------------------------------------------------------------- // CDP URL Resolution // --------------------------------------------------------------------------- /** * Resolve a CDP URL from a static string or async provider function. * @param cdpUrl - Static string or async function returning the CDP URL * @returns Resolved CDP URL string */ async resolveCdpUrl(cdpUrl) { return typeof cdpUrl === "function" ? await cdpUrl() : cdpUrl; } /** * Resolve an HTTP CDP endpoint to a WebSocket URL by fetching /json/version. * * Cloud browser providers (Browser-Use, Browserless, etc.) often expose HTTP * endpoints that need to be resolved to WebSocket URLs for direct CDP connections. * * - If the URL starts with `ws://` or `wss://`, returns it as-is * - If the URL starts with `http://` or `https://`, fetches /json/version to get webSocketDebuggerUrl * * @param url - CDP URL (HTTP or WebSocket) * @returns WebSocket URL for CDP connection */ async resolveWebSocketUrl(url) { if (url.startsWith("ws://") || url.startsWith("wss://")) { return url; } if (url.startsWith("http://") || url.startsWith("https://")) { const baseUrl = url.replace(/\/$/, ""); const versionUrl = `${baseUrl}/json/version`; this.logger.debug?.(`Resolving WebSocket URL from ${versionUrl}`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1e4); try { const response = await fetch(versionUrl, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error( `Failed to fetch CDP version info from ${versionUrl}: ${response.status} ${response.statusText}` ); } const data = await response.json(); if (!data.webSocketDebuggerUrl) { throw new Error(`No webSocketDebuggerUrl found in CDP version response from ${versionUrl}`); } this.logger.debug?.(`Resolved WebSocket URL: ${data.webSocketDebuggerUrl}`); return data.webSocketDebuggerUrl; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { throw new Error(`Timeout resolving WebSocket URL from ${versionUrl} (10s)`); } throw error; } } return url; } // --------------------------------------------------------------------------- // Disconnection Detection & Error Handling // --------------------------------------------------------------------------- /** * Error patterns that indicate browser disconnection. * Used by isDisconnectionError() to detect external browser closure. */ static DISCONNECTION_PATTERNS = [ "Target closed", "Target page, context or browser has been closed", "Browser has been closed", "Connection closed", "Protocol error", "Session closed", "browser has disconnected", "closed externally" ]; /** * Check if an error message indicates browser disconnection. * @param message - Error message to check * @returns true if the message indicates disconnection */ isDisconnectionError(message) { const lowerMessage = message.toLowerCase(); return _MastraBrowser.DISCONNECTION_PATTERNS.some((pattern) => lowerMessage.includes(pattern.toLowerCase())); } /** * Handle browser disconnection by updating status and notifying listeners. * Called when browser is detected as externally closed. * * For 'thread' scope: clears only the specific thread's session (other threads unaffected) * For 'shared' scope: clears the shared manager and updates global status */ handleBrowserDisconnected() { const scope = this.threadManager?.getScope(); const threadId = this.getCurrentThread(); if (scope === "thread" && threadId !== DEFAULT_THREAD_ID) { const pid = this.threadBrowserPids.get(threadId); killProcessGroup(pid, this.logger); this.threadBrowserPids.delete(threadId); this.threadManager.clearSession(threadId); this.logger.debug?.(`Cleared browser session for thread: ${threadId}`); this.notifyBrowserClosed(threadId); } else { killProcessGroup(this.sharedBrowserPid, this.logger); this.sharedBrowserPid = void 0; this.sharedManager = null; this.threadManager?.clearSharedManager(); if (this.status !== "closed") { this.status = "closed"; this.logger.debug?.("Browser was externally closed, status set to closed"); this.notifyBrowserClosed(); } } if (this.config.profile) { cleanupProfileLockFiles(this.config.profile, this.logger); } } /** * Create a BrowserToolError from an exception. * Handles common error patterns including disconnection detection. * Subclasses can override to add provider-specific error handling. * * @param error - The caught error * @param context - Description of what operation failed (e.g., "Click operation") * @returns Structured BrowserToolError */ createErrorFromException(error, context) { const msg = error instanceof Error ? error.message : String(error); if (this.isDisconnectionError(msg)) { this.handleBrowserDisconnected(); return createError( "browser_closed", "Browser was closed externally.", "The browser window was closed. Please retry to re-launch." ); } if (msg.includes("timeout") || msg.includes("Timeout") || msg.includes("aborted")) { return createError("timeout", `${context} timed out.`, "Try again or increase timeout."); } if (msg.includes("not launched") || msg.includes("Browser is not launched")) { return createError( "browser_error", "Browser was not initialized.", "This is an internal error - please try again." ); } return createError("browser_error", `${context} failed: ${msg}`, "Check the browser state and try again."); } /** * Create a specific error type. * Convenience method for providers to create typed errors. */ createError(code, message, hint) { return createError(code, message, hint); } // --------------------------------------------------------------------------- // Browser Ready/Closed Callbacks // --------------------------------------------------------------------------- _onReadyCallbacks = /* @__PURE__ */ new Set(); _onClosedCallbacks = /* @__PURE__ */ new Set(); /** Thread-specific ready callbacks. Key is threadId. */ _onThreadReadyCallbacks = /* @__PURE__ */ new Map(); /** Thread-specific closed callbacks. Key is threadId. */ _onThreadClosedCallbacks = /* @__PURE__ */ new Map(); /** * Register a callback to be invoked when the browser becomes ready. * If browser is already running, callback is invoked immediately. * The callback is ALWAYS registered (even if invoked immediately) so it will * also fire on future "ready" events (e.g., session creation for thread isolation). * @param callback - Function to call when browser is ready * @param threadId - Optional thread ID to scope the callback to a specific thread * @returns Cleanup function to unregister the callback */ onBrowserReady(callback, threadId) { if (threadId) { let threadCallbacks = this._onThreadReadyCallbacks.get(threadId); if (!threadCallbacks) { threadCallbacks = /* @__PURE__ */ new Set(); this._onThreadReadyCallbacks.set(threadId, threadCallbacks); } threadCallbacks.add(callback); if (this.hasThreadSession(threadId)) { callback(); } return () => { threadCallbacks.delete(callback); if (threadCallbacks.size === 0) { this._onThreadReadyCallbacks.delete(threadId); } }; } this._onReadyCallbacks.add(callback); if (this.isBrowserRunning()) { callback(); } return () => { this._onReadyCallbacks.delete(callback); }; } /** * Register a callback to be invoked when the browser closes. * Useful for screencast to broadcast browser_closed status. * @param callback - Function to call when browser closes * @param threadId - Optional thread ID to scope the callback to a specific thread * @returns Cleanup function to unregister the callback */ onBrowserClosed(callback, threadId) { if (threadId) { let threadCallbacks = this._onThreadClosedCallbacks.get(threadId); if (!threadCallbacks) { threadCallbacks = /* @__PURE__ */ new Set(); this._onThreadClosedCallbacks.set(threadId, threadCallbacks); } threadCallbacks.add(callback); return () => { threadCallbacks.delete(callback); if (threadCallbacks.size === 0) { this._onThreadClosedCallbacks.delete(threadId); } }; } this._onClosedCallbacks.add(callback); return () => { this._onClosedCallbacks.delete(callback); }; } /** * Notify registered callbacks that browser is ready. * @param threadId - If provided, only notify callbacks for that thread (for thread scope) */ notifyBrowserReady(threadId) { if (threadId) { const threadCallbacks = this._onThreadReadyCallbacks.get(threadId); if (threadCallbacks) { for (const callback of threadCallbacks) { try { callback(); } catch { } } } } else { for (const callback of this._onReadyCallbacks) { try { callback(); } catch { } } for (const [, threadCallbacks] of this._onThreadReadyCallbacks) { for (const callback of threadCallbacks) { try { callback(); } catch { } } } } } /** * Notify registered callbacks that browser has closed. * @param threadId - If provided, only notify callbacks for that thread (for thread scope) */ notifyBrowserClosed(threadId) { if (threadId) { const threadCallbacks = this._onThreadClosedCallbacks.get(threadId); if (threadCallbacks) { for (const callback of threadCallbacks) { try { callback(); } catch { } } } } else { for (const callback of this._onClosedCallbacks) { try { callback(); } catch { } } for (const [, threadCallbacks] of this._onThreadClosedCallbacks) { for (const callback of threadCallbacks) { try { callback(); } catch { } } } } } // --------------------------------------------------------------------------- // URL Access (optional - providers that support it should override) // --------------------------------------------------------------------------- /** * Get the current page URL without launching the browser. * @param threadId - Optional thread ID for thread-isolated browsers * @returns The current URL string, or null if browser is not running or not supported */ async getCurrentUrl(_threadId) { return null; } /** * Get the current browser state (all tabs and active tab index). * Override in subclass to provide actual tab state. * @param _threadId - Optional thread ID for thread-isolated sessions * @returns The browser state, or null if not available */ async getBrowserState(_threadId) { return null; } /** * Get the last known browser state before the browser was closed. * Useful for restoring state on relaunch. * @param threadId - Optional thread ID for thread-isolated sessions * @returns The last browser state, or undefined if not available */ getLastBrowserState(threadId) { if (threadId && this.threadManager) { const savedState = this.threadManager.getSavedBrowserState(threadId); if (savedState) { return savedState; } } return this.lastBrowserState; } /** * Get all open tabs with their URLs and titles. * Override in subclass to provide actual tab info. * @param _threadId - Optional thread ID for thread-isolated sessions * @returns Array of tab states */ async getTabState(_threadId) { return []; } /** * Get the active tab index. * Override in subclass to provide actual active tab index. * @param _threadId - Optional thread ID for thread-isolated sessions * @returns The active tab index (0-based), or 0 if not available */ async getActiveTabIndex(_threadId) { return 0; } /** * Navigate to a URL (simple form). Override in subclass if supported. * Used internally for restoring state on relaunch. * Named `navigateTo` to avoid conflicts with tool methods that have richer signatures. */ async navigateTo(_url) { } // --------------------------------------------------------------------------- // Thread Management // --------------------------------------------------------------------------- /** * Set the current thread ID for subsequent browser operations. * Called by tools before executing browser actions to ensure * operations are routed to the correct thread session. * * @param threadId - The thread ID, or undefined to use the default thread */ setCurrentThread(threadId) { this.currentThreadId = threadId ?? DEFAULT_THREAD_ID; } /** * Get the current thread ID. * @returns The current thread ID being used for operations */ getCurrentThread() { return this.currentThreadId; } /** * Get the browser scope mode. * @returns The scope from threadManager or config, defaults to 'shared' */ getScope() { return this.threadManager?.getScope() ?? this.config.scope ?? "shared"; } // --------------------------------------------------------------------------- // Screencast (optional - for Studio live view) // --------------------------------------------------------------------------- /** * Start screencast streaming. Override in subclass if supported. */ async startScreencast(_options) { throw new Error("Screencast not supported by this provider"); } /** * Check if a thread has an existing browser session. * Used by startScreencastIfBrowserActive to prevent showing another thread's page. * * If threadManager is set, delegates to it. Otherwise returns true (no isolation). * Subclasses can override for custom behavior. * * @returns true if session exists or thread isolation is not used */ hasThreadSession(threadId) { if (!this.threadManager) { return true; } const scope = this.threadManager.getScope(); if (scope === "shared") { return true; } return this.threadManager.hasSession(threadId); } /** * Close a specific thread's browser session. * Delegates to ThreadManager and notifies registered callbacks. * * For 'thread' scope, this closes only that thread's browser instance. * For 'shared' scope, this is a no-op (use close() to close the shared browser). * * @param threadId - The thread ID whose session should be closed */ async closeThreadSession(threadId) { if (!this.threadManager) { return; } await this.threadManager.destroySession(threadId); const pid = this.threadBrowserPids.get(threadId); killProcessGroup(pid, this.logger); this.threadBrowserPids.delete(threadId); this.notifyBrowserClosed(threadId); if (this.config.profile) { cleanupProfileLockFiles(this.config.profile, this.logger); } } /** * Handle browser disconnection for a specific thread. * Called when a thread's browser is closed externally (e.g., user closes browser window). * Clears the thread session and notifies registered callbacks. * * @param threadId - The thread ID whose session was disconnected */ handleThreadBrowserDisconnected(threadId) { if (!this.threadManager) { return; } const pid = this.threadBrowserPids.get(threadId); killProcessGroup(pid, this.logger); this.threadBrowserPids.delete(threadId); this.threadManager.clearSession(threadId); this.logger.debug?.(`Cleared browser session for thread: ${threadId}`); this.notifyBrowserClosed(threadId); if (this.config.profile) { cleanupProfileLockFiles(this.config.profile, this.logger); } } /** * Get a session identifier for a specific thread. * In thread scope, returns a composite ID (browser:threadId). * In shared scope or without thread manager, returns the browser instance ID. */ getSessionId(threadId) { if (!threadId || !this.threadManager) { return this.id; } const scope = this.threadManager.getScope(); if (scope === "shared") { return this.id; } return `${this.id}:${threadId}`; } /** * Start screencast only if browser is already running. * Does NOT launch the browser. * Uses config.screencast options as defaults if no options provided. * * For thread-isolated browsers ('browser' mode): * - Returns null if the thread doesn't have an existing browser session */ async startScreencastIfBrowserActive(options) { const mergedOptions = this.config.screencast || options ? { ...this.config.screencast, ...options } : void 0; const threadId = mergedOptions?.threadId; const scope = this.threadManager?.getScope() ?? this.config.scope ?? "shared"; if (!this.isBrowserRunning(threadId)) { return null; } if (scope === "shared") { return this.startScreencast(mergedOptions); } if (threadId && !this.hasThreadSession(threadId)) { console.log( `[MastraBrowser] startScreencastIfBrowserActive: hasThreadSession(${threadId})=false, scope=${scope}` ); return null; } return this.startScreencast(mergedOptions); } // --------------------------------------------------------------------------- // Event Injection (optional - for Studio live view) // --------------------------------------------------------------------------- /** * Inject a mouse event. Override in subclass if supported. * @param event - Mouse event parameters * @param threadId - Optional thread ID for thread-isolated sessions */ async injectMouseEvent(_event, _threadId) { throw new Error("Mouse event injection not supported by this provider"); } /** * Inject a keyboard event. Override in subclass if supported. * @param event - Keyboard event parameters * @param threadId - Optional thread ID for thread-isolated sessions */ async injectKeyboardEvent(_event, _threadId) { throw new Error("Keyboard event injection not supported by this provider"); } // --------------------------------------------------------------------------- // Input Processors // --------------------------------------------------------------------------- /** * Returns browser input processors (e.g., BrowserContextProcessor for context injection). * Skips if the user already added a processor with the same id. * * This method is similar to AgentChannels.getInputProcessors() and allows * browser implementations to provide their own processors. * * @param configuredProcessors - Processors already configured by the user (for deduplication) * @returns Array of input processors for this browser instance */ getInputProcessors(configuredProcessors = []) { const hasProcessor = configuredProcessors.some( (p) => !chunkACQ5CVFF_cjs.isProcessorWorkflow(p) && "id" in p && p.id === "browser-context" ); if (hasProcessor) return []; return [new BrowserContextProcessor()]; } }; // src/browser/screencast/types.ts var SCREENCAST_DEFAULTS = { format: "jpeg", quality: 80, maxWidth: 1280, maxHeight: 720, everyNthFrame: 1 }; var ScreencastStream = class extends events.EventEmitter { /** Whether screencast is currently active */ active = false; /** Resolved options with defaults applied (excludes threadId which is only used for page selection) */ options; /** CDP session provider */ provider; /** Current CDP session */ cdpSession = null; /** Frame handler reference (for cleanup) */ frameHandler = null; /** * Creates a new ScreencastStream. * * @param provider - CDP session provider (browser instance) * @param options - Screencast configuration options */ constructor(provider, options) { super(); this.provider = provider; const { threadId: _, ...cdpOptions } = options ?? {}; this.options = { ...SCREENCAST_DEFAULTS, ...cdpOptions }; } /** * Start the screencast. * If already active, returns immediately. */ async start() { if (this.active) { return; } if (!this.provider.isBrowserRunning()) { throw new Error("Browser is not running"); } try { this.cdpSession = await this.provider.getCdpSession(); this.frameHandler = (params) => { const frameData = { data: params.data, // CDP provides timestamp in seconds, convert to milliseconds for consistency timestamp: params.metadata?.timestamp ? params.metadata.timestamp * 1e3 : Date.now(), viewport: { width: params.metadata?.deviceWidth ?? 0, height: params.metadata?.deviceHeight ?? 0, offsetTop: params.metadata?.offsetTop, scrollOffsetX: params.metadata?.scrollOffsetX, scrollOffsetY: params.metadata?.scrollOffsetY, pageScaleFactor: params.metadata?.pageScaleFactor }, sessionId: params.sessionId }; this.emit("frame", frameData); this.acknowledgeFrame(params.sessionId); }; this.cdpSession.on("Page.screencastFrame", this.frameHandler); try { await this.cdpSession.send("Page.startScreencast", { format: this.options.format, quality: this.options.quality, maxWidth: this.options.maxWidth, maxHeight: this.options.maxHeight, everyNthFrame: this.options.everyNthFrame }); } catch (startError) { if (this.cdpSession?.off) { try { this.cdpSession.off("Page.screencastFrame", this.frameHandler); } catch { } } this.frameHandler = null; this.cdpSession = null; throw startError; } this.active = true; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.emit("error", err); throw err; } } /** * Acknowledge a frame to CDP (required to continue receiving frames). */ acknowledgeFrame(sessionId) { if (!this.cdpSession) return; this.cdpSession.send("Page.screencastFrameAck", { sessionId }).catch(() => { }); } /** * Stop the screencast and release resources. * Safe to call even if browser/CDP session is already closed. */ async stop() { if (!this.active) { return; } this.active = false; let hadError = false; if (this.cdpSession && this.frameHandler && this.cdpSession.off) { try { this.cdpSession.off("Page.screencastFrame", this.frameHandler); } catch { } } this.frameHandler = null; if (this.cdpSession) { try { await this.cdpSession.send("Page.stopScreencast"); } catch { hadError = true; } this.cdpSession = null; } this.emit("stop", hadError ? "error" : "manual"); } /** * Check if screencast is currently active. */ isActive() { return this.active; } /** * Emit a URL update event. * Browser providers call this when navigation is detected. */ emitUrl(url) { this.emit("url", url); } /** * Reconnect the screencast by stopping and restarting. * Use this when the active page/tab changes. * * @returns Promise that resolves when reconnection is complete * @throws Error if reconnection fails (also emits 'error' event) */ async reconnect() { if (this.cdpSession && this.frameHandler && this.cdpSession.off) { try { this.cdpSession.off("Page.screencastFrame", this.frameHandler); } catch { } } this.frameHandler = null; if (this.cdpSession) { try { await this.cdpSession.send("Page.stopScreencast"); } catch { } this.cdpSession = null; } this.active = false; try { await this.start(); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); console.error("[ScreencastStream.reconnect] Failed to reconnect:", err); throw err; } } }; Object.defineProperty(exports, "BrowserCliHandler", { enumerable: true, get: function () { return chunk7OCF5TOO_cjs.BrowserCliHandler; } }); Object.defineProperty(exports, "browserCliHandler", { enumerable: true, get: function () { return chunk7OCF5TOO_cjs.browserCliHandler; } }); exports.BrowserContextProcessor = BrowserContextProcessor; exports.DEFAULT_THREAD_ID = DEFAULT_THREAD_ID; exports.MastraBrowser = MastraBrowser; exports.SCREENCAST_DEFAULTS = SCREENCAST_DEFAULTS; exports.ScreencastStreamImpl = ScreencastStream; exports.ThreadManager = ThreadManager; exports.createError = createError; //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map