UNPKG

@mastra/core

Version:
1 lines 188 kB
{"version":3,"file":"index.cjs","names":["MastraBase","RegisteredLogger","isProcessorWorkflow","EventEmitter","sep","z","createTool"],"sources":["../../src/browser/errors.ts","../../src/browser/processor.ts","../../src/browser/thread-manager.ts","../../src/browser/browser.ts","../../src/browser/screencast/types.ts","../../src/browser/screencast/screencast-stream.ts","../../src/browser/recording/mjpeg-avi.ts","../../src/browser/recording/overlay.ts","../../src/browser/recording/tools.ts"],"sourcesContent":["/**\n * Unified error handling for browser tools.\n *\n * All browser tools return errors in this consistent format,\n * providing LLM-friendly messages and recovery hints.\n */\n\n/**\n * Error codes for browser tool failures.\n *\n * These codes help agents understand what went wrong\n * and whether retry or recovery is possible.\n */\nexport type ErrorCode =\n | 'stale_ref' // Ref no longer valid after page change\n | 'element_not_found' // Element doesn't exist\n | 'element_blocked' // Element covered by overlay\n | 'element_not_visible' // Element hidden\n | 'not_focusable' // Can't type into element\n | 'timeout' // Operation timed out\n | 'browser_closed' // Browser was externally closed\n | 'browser_error'; // Generic browser error\n\n/**\n * Structured error response for browser tool failures.\n *\n * Provides LLM-friendly error information with optional recovery hints.\n */\nexport interface BrowserToolError {\n /** Always false for error responses */\n success: false;\n /** Error classification code */\n code: ErrorCode;\n /** LLM-friendly error description */\n message: string;\n /** Suggested recovery action (only when actionable) */\n recoveryHint?: string;\n /** Whether the operation can be retried */\n canRetry: boolean;\n}\n\n/**\n * Error codes that are generally retryable.\n */\nconst RETRYABLE_CODES: Set<ErrorCode> = new Set(['timeout', 'element_blocked']);\n\n/**\n * Creates a structured error response for browser tools.\n *\n * Sets canRetry based on the error code: true for 'timeout' and 'element_blocked'.\n *\n * @param code - Error classification code\n * @param message - LLM-friendly error description\n * @param hint - Optional recovery hint (only when actionable)\n * @returns Typed BrowserToolError with canRetry set automatically\n */\nexport function createError(code: ErrorCode, message: string, hint?: string): BrowserToolError {\n return {\n success: false,\n code,\n message,\n recoveryHint: hint,\n canRetry: RETRYABLE_CODES.has(code),\n };\n}\n","/**\n * BrowserContextProcessor\n *\n * Input processor that injects browser context into agent prompts.\n * Similar to ChatChannelProcessor for channels.\n *\n * - `processInput`: Adds a system message with stable context (provider, sessionId, headless mode).\n * - `processInputStep`: At step 0, adds a new user message with browser context as a `<system-reminder>`.\n * This preserves prompt cache by not modifying existing messages in history.\n *\n * Reads from `requestContext.get('browser')`.\n *\n * @example\n * ```ts\n * const agent = new Agent({\n * browser: new AgentBrowser({ ... }),\n * inputProcessors: [new BrowserContextProcessor()],\n * });\n * ```\n */\n\nimport { randomUUID } from 'node:crypto';\nimport type {\n ComputeStateSignalArgs,\n ComputeStateSignalResult,\n ProcessInputArgs,\n ProcessInputResult,\n} from '../processors/index';\n\nconst BROWSER_PROCESS_ID = randomUUID();\n\n/**\n * Browser context stored in RequestContext.\n * Set by the browser implementation or deployer.\n */\nexport interface BrowserContext {\n /** Browser provider name (e.g., \"agent-browser\", \"stagehand\") */\n provider: string;\n\n /** Provider type: 'sdk' for direct API, 'cli' for command-line tools */\n providerType?: 'sdk' | 'cli';\n\n /** Session ID for tracking */\n sessionId?: string;\n\n /** Whether browser is running in headless mode */\n headless?: boolean;\n\n /** Current page URL (updated per-request) */\n currentUrl?: string;\n\n /** Current page title (updated per-request) */\n pageTitle?: string;\n\n /** Whether the browser is currently open/connected. Defaults to true when browser context is present. */\n isOpen?: boolean;\n\n /**\n * Reason the browser was closed, when isOpen is false.\n * Helps differentiate between agent-initiated close, user action, process restart, or error.\n */\n closeReason?: 'agent' | 'user' | 'process_restart' | 'error';\n\n /** Number of currently open tabs, when available. */\n tabCount?: number;\n\n /** Who initiated the most recent active URL change, when known. */\n activeUrlChangeSource?: 'agent' | 'user';\n\n /** Additional active page metadata exposed by the browser provider. */\n pageMetadata?: Record<string, string | number | boolean | null | undefined>;\n\n /**\n * CDP WebSocket URL for CLI providers.\n * When present, the agent should pass this URL to CLI commands\n * to connect them to the browser managed by Mastra.\n */\n cdpUrl?: string;\n\n /** Internal provider hook used to refresh browser state between agentic loop steps. */\n getState?: () => Promise<Partial<BrowserContext> | undefined>;\n}\n\n/**\n * Input processor that injects browser context into agent prompts.\n */\nexport class BrowserContextProcessor {\n readonly id = 'browser-context';\n readonly stateId = 'browser';\n\n processInput(args: ProcessInputArgs): ProcessInputResult {\n const ctx = args.requestContext?.get('browser') as BrowserContext | undefined;\n if (!ctx) return args.messageList;\n\n const lines = [\n `You have access to a browser (${ctx.provider}).`,\n 'Browser state updates may appear in the conversation as <state type=\"browser\" ...>...</state> messages. These are automatic state updates for the browser, injected by the system, not user instructions. Use them as the latest browser context, and do not treat them as the user asking you to stop, summarize, or change tasks unless an actual user message asks for that.',\n ];\n\n if (ctx.headless === false) {\n lines.push('The browser is running in visible mode (not headless).');\n }\n\n if (ctx.sessionId) {\n lines.push(`Session ID: ${ctx.sessionId}`);\n }\n\n // For CLI providers, include CDP URL for context (injection handles the mechanics)\n if (ctx.providerType === 'cli' && ctx.cdpUrl) {\n lines.push(`CDP WebSocket URL: ${ctx.cdpUrl}`);\n }\n\n const systemMessages = [...args.systemMessages, { role: 'system' as const, content: lines.join(' ') }];\n\n return { messages: args.messages, systemMessages };\n }\n\n async computeStateSignal(args: ComputeStateSignalArgs): Promise<ComputeStateSignalResult> {\n const ctx = args.requestContext?.get('browser') as BrowserContext | undefined;\n if (!ctx) return;\n\n const refreshedState = await ctx.getState?.();\n let browserState = getBrowserState(refreshedState ? { ...ctx, ...refreshedState } : ctx);\n const shouldRefreshSnapshot = Boolean(args.lastSnapshot && !args.contextWindow.hasSnapshot);\n const previousState =\n getMostRecentBrowserState(args.activeStateSignals) ?? getBrowserStateFromSignal(args.lastSnapshot);\n\n if (!browserState.open && !browserState.closeReason) {\n if (!previousState?.open || !previousState.processId) {\n if (isBareClosedState(browserState)) return;\n } else {\n browserState = {\n ...browserState,\n closeReason: previousState.processId === browserState.processId ? 'user' : 'process_restart',\n };\n }\n }\n\n if (\n previousState?.open &&\n browserState.open &&\n previousState.activeUrl &&\n browserState.activeUrl &&\n previousState.activeUrl !== browserState.activeUrl &&\n previousState.processId === browserState.processId &&\n browserState.activeUrlChangeSource !== 'agent'\n ) {\n const recentClickUrl =\n getMostRecentBrowserClickResultUrl(args.steps) ??\n getMostRecentBrowserClickResultUrlFromMessageList(args.messageList);\n const activeUrlChangeSource =\n recentClickUrl === browserState.activeUrl ? 'agent' : (browserState.activeUrlChangeSource ?? 'user');\n browserState = {\n ...browserState,\n activeUrlChangeSource,\n };\n }\n\n const changed = getChangedBrowserState(previousState, browserState);\n if (previousState && Object.keys(changed).length === 0 && !shouldRefreshSnapshot) return;\n\n const isDelta = Boolean(previousState && !shouldRefreshSnapshot);\n const result = {\n id: 'browser',\n cacheKey: stableBrowserStateCacheKey(browserState),\n mode: isDelta ? 'delta' : 'snapshot',\n tagName: 'state',\n contents: isDelta ? formatBrowserStateDelta(changed) : formatBrowserStateSnapshot(browserState),\n value: browserState,\n ...(isDelta ? { delta: changed } : {}),\n attributes: {\n type: 'browser',\n updated: new Date().toISOString(),\n },\n metadata: {\n browser: browserState,\n },\n } satisfies Exclude<ComputeStateSignalResult, undefined | void>;\n return result;\n }\n}\n\ntype BrowserState = {\n processId: string;\n open: boolean;\n activeUrl?: string;\n pageTitle?: string;\n tabCount?: number;\n activeUrlChangeSource?: 'agent' | 'user';\n pageMetadata?: Record<string, string | number | boolean | null | undefined>;\n closeReason?: 'agent' | 'user' | 'process_restart' | 'error';\n};\n\nfunction getBrowserState(ctx: BrowserContext): BrowserState {\n return {\n processId: BROWSER_PROCESS_ID,\n open: ctx.isOpen ?? true,\n ...(ctx.currentUrl ? { activeUrl: ctx.currentUrl } : {}),\n ...(ctx.pageTitle ? { pageTitle: ctx.pageTitle } : {}),\n ...(typeof ctx.tabCount === 'number' ? { tabCount: ctx.tabCount } : {}),\n ...(ctx.activeUrlChangeSource ? { activeUrlChangeSource: ctx.activeUrlChangeSource } : {}),\n ...(ctx.pageMetadata ? { pageMetadata: ctx.pageMetadata } : {}),\n ...(ctx.closeReason ? { closeReason: ctx.closeReason } : {}),\n };\n}\n\nfunction getMostRecentBrowserState(\n activeStateSignals: ComputeStateSignalArgs['activeStateSignals'],\n): BrowserState | undefined {\n for (const signal of [...activeStateSignals].reverse()) {\n const browserState = getBrowserStateFromSignal(signal);\n if (browserState) return browserState;\n }\n return undefined;\n}\n\nfunction getMostRecentBrowserClickResultUrl(steps: ComputeStateSignalArgs['steps']): string | undefined {\n for (const step of [...steps].reverse()) {\n const toolResults = Array.isArray(step.toolResults) ? step.toolResults : [];\n for (const toolResult of [...toolResults].reverse()) {\n if (toolResult?.toolName !== 'browser_click') continue;\n const url = getBrowserClickResultUrl(getToolResultOutputForAttribution(toolResult));\n if (url) return url;\n }\n }\n return undefined;\n}\n\nfunction getMostRecentBrowserClickResultUrlFromMessageList(\n messageList: ComputeStateSignalArgs['messageList'],\n): string | undefined {\n try {\n return getMostRecentBrowserClickResultUrlFromMessages(messageList.get.all.db());\n } catch {\n return undefined;\n }\n}\n\nfunction getMostRecentBrowserClickResultUrlFromMessages(\n messages: ComputeStateSignalArgs['messages'],\n): string | undefined {\n for (const message of [...messages].reverse()) {\n if (isBrowserStateSignalMessage(message)) return undefined;\n\n const content = message.content;\n if (!content || typeof content !== 'object' || Array.isArray(content)) continue;\n const parts = (content as { parts?: unknown }).parts;\n if (!Array.isArray(parts)) continue;\n\n for (const part of [...parts].reverse()) {\n if (!part || typeof part !== 'object' || Array.isArray(part)) continue;\n const toolInvocation = (part as { toolInvocation?: unknown }).toolInvocation;\n if (!toolInvocation || typeof toolInvocation !== 'object' || Array.isArray(toolInvocation)) continue;\n const invocation = toolInvocation as Record<string, unknown>;\n if (invocation.toolName !== 'browser_click' || invocation.state !== 'result') continue;\n const url = getBrowserClickResultUrl(invocation.result ?? invocation.output);\n if (url) return url;\n }\n }\n return undefined;\n}\n\nfunction isBrowserStateSignalMessage(message: ComputeStateSignalArgs['messages'][number]): boolean {\n const content = message.content;\n if (!content || typeof content !== 'object' || Array.isArray(content)) return false;\n const signal = (content as { metadata?: { signal?: unknown } }).metadata?.signal;\n if (!signal || typeof signal !== 'object' || Array.isArray(signal)) return false;\n const metadata = (signal as { metadata?: unknown }).metadata;\n if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return false;\n const state = (metadata as { state?: unknown }).state;\n if (!state || typeof state !== 'object' || Array.isArray(state)) return false;\n return (state as { id?: unknown }).id === 'browser';\n}\n\nfunction getBrowserClickResultUrl(output: unknown): string | undefined {\n if (!output || typeof output !== 'object' || Array.isArray(output)) return undefined;\n if ((output as { success?: unknown }).success !== true) return undefined;\n const url = (output as { url?: unknown }).url;\n return typeof url === 'string' && url.length > 0 ? url : undefined;\n}\n\nfunction getToolResultOutputForAttribution(toolResult: unknown): unknown {\n if (!toolResult || typeof toolResult !== 'object' || Array.isArray(toolResult)) return undefined;\n const record = toolResult as Record<string, unknown>;\n return record.output ?? record.result;\n}\n\nfunction getBrowserStateFromSignal(signal?: ComputeStateSignalArgs['lastSnapshot']): BrowserState | undefined {\n const value = signal?.metadata?.value;\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value as BrowserState;\n }\n\n const browser = signal?.metadata?.browser;\n if (browser && typeof browser === 'object' && !Array.isArray(browser)) {\n return browser as BrowserState;\n }\n return undefined;\n}\n\nfunction stableBrowserStateCacheKey(state: BrowserState): string {\n return JSON.stringify(state, (_key, value) => {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n const sorted: Record<string, unknown> = {};\n for (const key of Object.keys(value as Record<string, unknown>).sort()) {\n sorted[key] = (value as Record<string, unknown>)[key];\n }\n return sorted;\n }\n return value;\n });\n}\n\nfunction isBareClosedState(state: BrowserState): boolean {\n return (\n !state.open &&\n !state.activeUrl &&\n !state.pageTitle &&\n typeof state.tabCount !== 'number' &&\n !state.closeReason &&\n (!state.pageMetadata || Object.keys(state.pageMetadata).length === 0)\n );\n}\n\nfunction getChangedBrowserState(previous: BrowserState | undefined, current: BrowserState): Partial<BrowserState> {\n if (!previous) return current;\n\n const changed: Partial<BrowserState> = {};\n for (const key of Object.keys(current) as Array<keyof BrowserState>) {\n if (key === 'processId') continue;\n if (key === 'activeUrlChangeSource' && previous.activeUrl === current.activeUrl) continue;\n if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) {\n (changed as Record<string, unknown>)[key] = current[key];\n }\n }\n\n if (previous.activeUrl !== current.activeUrl && current.activeUrlChangeSource) {\n changed.activeUrlChangeSource = current.activeUrlChangeSource;\n }\n\n if (!previous.open && current.open) {\n if (current.activeUrl) changed.activeUrl = current.activeUrl;\n if (current.activeUrlChangeSource) changed.activeUrlChangeSource = current.activeUrlChangeSource;\n if (current.pageTitle) changed.pageTitle = current.pageTitle;\n if (typeof current.tabCount === 'number') changed.tabCount = current.tabCount;\n if (current.pageMetadata && Object.keys(current.pageMetadata).length > 0)\n changed.pageMetadata = current.pageMetadata;\n }\n\n return changed;\n}\n\nfunction formatBrowserStateSnapshot(state: BrowserState): string {\n const parts = [formatOpenClosedStatus(state)];\n if (state.activeUrl) parts.push(`Active tab URL: ${state.activeUrl}.`);\n if (state.pageTitle) parts.push(`Page title: ${state.pageTitle}.`);\n if (typeof state.tabCount === 'number')\n parts.push(`${state.tabCount} open ${state.tabCount === 1 ? 'tab' : 'tabs'}.`);\n if (state.pageMetadata && Object.keys(state.pageMetadata).length > 0) {\n parts.push(`Page metadata: ${JSON.stringify(state.pageMetadata)}.`);\n }\n return parts.join(' ');\n}\n\nfunction formatBrowserStateDelta(delta: Partial<BrowserState>): string {\n const parts: string[] = [];\n if (typeof delta.open === 'boolean') {\n if (delta.open) {\n parts.push('browser opened');\n } else {\n parts.push(formatCloseReason(delta.closeReason));\n }\n }\n if (delta.activeUrl) parts.push(formatActiveUrlChange(delta.activeUrl, delta.activeUrlChangeSource));\n if (delta.pageTitle) parts.push(`page title changed to ${delta.pageTitle}`);\n if (typeof delta.tabCount === 'number') parts.push(`${delta.tabCount} open ${delta.tabCount === 1 ? 'tab' : 'tabs'}`);\n if (delta.pageMetadata && Object.keys(delta.pageMetadata).length > 0) {\n parts.push(`page metadata changed to ${JSON.stringify(delta.pageMetadata)}`);\n }\n return `changed: ${parts.join('; ')}`;\n}\n\nfunction formatActiveUrlChange(url: string, source?: 'agent' | 'user'): string {\n switch (source) {\n case 'agent':\n return `agent changed active tab URL to ${url}`;\n case 'user':\n return `user changed active tab URL to ${url}`;\n default:\n return `active tab URL changed to ${url}`;\n }\n}\n\nfunction formatOpenClosedStatus(state: BrowserState): string {\n if (state.open) return 'Browser is open.';\n switch (state.closeReason) {\n case 'process_restart':\n return 'The browser was closed because the chat process restarted.';\n case 'user':\n return 'The browser was closed externally, maybe by the user.';\n case 'error':\n return 'Browser closed unexpectedly due to an error.';\n case 'agent':\n return 'Browser is closed.';\n default:\n return 'Browser is closed.';\n }\n}\n\nfunction formatCloseReason(reason?: 'agent' | 'user' | 'process_restart' | 'error'): string {\n switch (reason) {\n case 'process_restart':\n return 'the browser was closed because the chat process restarted';\n case 'user':\n return 'the browser was closed externally, maybe by the user';\n case 'error':\n return 'browser closed unexpectedly due to an error';\n case 'agent':\n return 'browser closed';\n default:\n return 'browser closed';\n }\n}\n","/**\n * ThreadManager - Abstract base class for managing thread-scoped browser sessions.\n *\n * Similar to ProcessManager for workspaces, this centralizes thread lifecycle logic\n * and makes thread isolation reusable across browser providers.\n *\n * Browser scope modes:\n * - 'shared': All threads share a single browser instance\n * - 'thread': Each thread gets its own browser instance (full isolation)\n */\n\nimport type { IMastraLogger } from '../logger';\n\n/** Browser scope mode - determines how browser instances are shared across threads */\nexport type BrowserScope = 'shared' | 'thread';\n\n/** Default thread ID used when no thread is specified */\nexport const DEFAULT_THREAD_ID = '__default__';\n\n/**\n * Represents a single tab's state for persistence.\n */\nexport interface BrowserTabState {\n url: string;\n title?: string;\n}\n\n/**\n * Full browser state for persistence and restoration.\n */\nexport interface BrowserState {\n tabs: BrowserTabState[];\n activeTabIndex: number;\n /** Reason the browser was closed, when this is the last known state for a closed browser. */\n closeReason?: 'agent' | 'user' | 'process_restart' | 'error';\n /** Who initiated the most recent active URL change, when known. */\n activeUrlChangeSource?: 'agent' | 'user';\n}\n\n/**\n * Represents an active thread session.\n */\nexport interface ThreadSession {\n /** Unique thread identifier */\n threadId: string;\n /** Timestamp when session was created */\n createdAt: number;\n /** Full browser state for this thread (for restore on relaunch) */\n browserState?: BrowserState;\n}\n\n/**\n * Configuration for ThreadManager.\n */\nexport interface ThreadManagerConfig {\n /** Browser scope mode */\n scope: BrowserScope;\n /** Logger instance */\n logger?: IMastraLogger;\n /** Callback when a new session is created */\n onSessionCreated?: (session: ThreadSession) => void;\n /** Callback when a session is destroyed */\n onSessionDestroyed?: (threadId: string) => void;\n}\n\n/**\n * Abstract base class for managing thread-scoped browser sessions.\n *\n * @typeParam TManager - The browser manager type (e.g., BrowserManagerLike, Stagehand)\n */\nexport abstract class ThreadManager<TManager = unknown> {\n protected readonly scope: BrowserScope;\n protected readonly logger?: IMastraLogger;\n protected readonly sessions = new Map<string, ThreadSession>();\n protected activeThreadId: string = DEFAULT_THREAD_ID;\n\n /** Preserved browser state that survives session clears (for browser restore) */\n protected readonly savedBrowserStates = new Map<string, BrowserState>();\n\n /** Shared manager instance (used for 'shared' scope) */\n protected sharedManager: TManager | null = null;\n\n /** Map of thread ID to dedicated manager instance (for 'thread' scope) */\n protected readonly threadManagers = new Map<string, TManager>();\n\n protected readonly onSessionCreated?: (session: ThreadSession) => void;\n protected readonly onSessionDestroyed?: (threadId: string) => void;\n\n constructor(config: ThreadManagerConfig) {\n this.scope = config.scope;\n this.logger = config.logger;\n this.onSessionCreated = config.onSessionCreated;\n this.onSessionDestroyed = config.onSessionDestroyed;\n }\n\n /**\n * Get the current browser scope mode.\n */\n getScope(): BrowserScope {\n return this.scope;\n }\n\n /**\n * Get the currently active thread ID.\n */\n getActiveThreadId(): string {\n return this.activeThreadId;\n }\n\n /**\n * Set the shared manager instance (called after browser launch).\n */\n setSharedManager(manager: TManager): void {\n this.sharedManager = manager;\n }\n\n /**\n * Clear the shared manager instance (called when browser disconnects).\n */\n clearSharedManager(): void {\n this.sharedManager = null;\n }\n\n /**\n * Get the manager for an existing thread session without creating a new one.\n *\n * For 'thread' scope: Returns the thread-specific manager, or null if no session exists.\n * For 'shared' scope: Returns the shared manager (all threads use the same instance).\n *\n * @param threadId - Thread identifier (defaults to DEFAULT_THREAD_ID)\n * @returns The manager for the thread, or null if not found (thread scope only)\n */\n getExistingManagerForThread(threadId?: string): TManager | null {\n const effectiveThreadId = threadId ?? DEFAULT_THREAD_ID;\n if (this.scope === 'thread') {\n return this.threadManagers.get(effectiveThreadId) ?? null;\n }\n return this.sharedManager;\n }\n\n /**\n * Check if any thread managers are still running (for 'thread' scope).\n */\n hasActiveThreadManagers(): boolean {\n return this.threadManagers.size > 0;\n }\n\n /**\n * Clear all session tracking without closing managers.\n * Used when browsers have been externally closed and we just need to reset state.\n */\n clearAllSessions(): void {\n this.threadManagers.clear();\n this.sessions.clear();\n this.activeThreadId = DEFAULT_THREAD_ID;\n }\n\n /**\n * Get a session by thread ID.\n */\n getSession(threadId: string): ThreadSession | undefined {\n return this.sessions.get(threadId);\n }\n\n /**\n * Check if a session exists for a thread.\n */\n hasSession(threadId: string): boolean {\n return this.sessions.has(threadId);\n }\n\n /**\n * List all active sessions.\n */\n listSessions(): ThreadSession[] {\n return Array.from(this.sessions.values());\n }\n\n /**\n * Get the number of active sessions.\n */\n getSessionCount(): number {\n return this.sessions.size;\n }\n\n /**\n * Get or create a session for a thread, and return the browser manager for that thread.\n *\n * For 'shared' scope, returns the shared manager.\n * For 'thread' scope, creates/returns a dedicated manager for the thread.\n *\n * @param threadId - Thread identifier (uses DEFAULT_THREAD_ID if not provided)\n * @returns The browser manager for the thread\n */\n async getManagerForThread(threadId?: string): Promise<TManager> {\n const effectiveThreadId = threadId ?? DEFAULT_THREAD_ID;\n\n // Shared scope - always use shared manager\n // For thread scope, always create/use a dedicated session (even for DEFAULT_THREAD_ID)\n if (this.scope === 'shared') {\n return this.getSharedManager();\n }\n\n // Check if session already exists\n let session = this.sessions.get(effectiveThreadId);\n\n if (!session) {\n // Create new session\n session = await this.createSession(effectiveThreadId);\n this.sessions.set(effectiveThreadId, session);\n this.logger?.debug?.(`Created thread session: ${effectiveThreadId}`);\n this.onSessionCreated?.(session);\n }\n\n this.activeThreadId = effectiveThreadId;\n return this.getManagerForSession(session);\n }\n\n /**\n * Destroy a specific thread's session.\n *\n * @param threadId - Thread identifier\n */\n async destroySession(threadId: string): Promise<void> {\n const session = this.sessions.get(threadId);\n if (!session) {\n return;\n }\n\n await this.doDestroySession(session);\n this.threadManagers.delete(threadId);\n this.sessions.delete(threadId);\n this.logger?.debug?.(`Destroyed thread session: ${threadId}`);\n this.onSessionDestroyed?.(threadId);\n\n // Reset active thread if we destroyed it\n if (this.activeThreadId === threadId) {\n this.activeThreadId = DEFAULT_THREAD_ID;\n }\n }\n\n /**\n * Destroy all thread sessions.\n */\n async destroyAllSessions(): Promise<void> {\n const threadIds = Array.from(this.sessions.keys());\n for (const threadId of threadIds) {\n await this.destroySession(threadId);\n }\n this.activeThreadId = DEFAULT_THREAD_ID;\n }\n\n /**\n * Update the browser state for a thread session.\n * Also saves to persistent storage so state survives session clears.\n */\n updateBrowserState(threadId: string, state: BrowserState): void {\n // Filter out empty/blank tabs\n const filteredTabs = state.tabs.filter(tab => tab.url && tab.url !== 'about:blank');\n if (filteredTabs.length === 0) {\n return;\n }\n\n const filteredState: BrowserState = {\n ...state,\n tabs: filteredTabs,\n activeTabIndex: Math.max(0, Math.min(state.activeTabIndex, filteredTabs.length - 1)),\n };\n\n const session = this.sessions.get(threadId);\n if (session) {\n session.browserState = filteredState;\n }\n // Also save to persistent map so it survives session clears\n this.savedBrowserStates.set(threadId, filteredState);\n }\n\n /**\n * Get the saved browser state for a thread (survives session clears).\n */\n getSavedBrowserState(threadId: string): BrowserState | undefined {\n // First check current session\n const session = this.sessions.get(threadId);\n if (session?.browserState) {\n return session.browserState;\n }\n // Fall back to saved state\n return this.savedBrowserStates.get(threadId);\n }\n\n /**\n * Clear a specific thread's session without closing the browser.\n * Used when a thread's browser has been externally closed.\n * Preserves the browser state for potential restoration.\n *\n * @param threadId - The thread ID to clear\n */\n clearSession(threadId: string): void {\n // Save the browser state before clearing so it can be restored on relaunch\n const session = this.sessions.get(threadId);\n if (session?.browserState) {\n this.savedBrowserStates.set(threadId, session.browserState);\n }\n this.threadManagers.delete(threadId);\n this.sessions.delete(threadId);\n // Reset activeThreadId if we just cleared it\n if (this.activeThreadId === threadId) {\n this.activeThreadId = DEFAULT_THREAD_ID;\n }\n }\n\n // ---------------------------------------------------------------------------\n // Abstract methods to be implemented by subclasses\n // ---------------------------------------------------------------------------\n\n /**\n * Get the shared browser manager (used for 'shared' scope and default thread).\n * @throws Error if shared manager is not initialized\n */\n protected getSharedManager(): TManager {\n if (!this.sharedManager) {\n throw new Error('Browser not launched');\n }\n return this.sharedManager;\n }\n\n /**\n * Create a new session for a thread.\n * Called when a thread is accessed for the first time.\n */\n protected abstract createSession(threadId: string): Promise<ThreadSession>;\n\n /**\n * Get the browser manager for a specific session.\n */\n protected abstract getManagerForSession(session: ThreadSession): TManager;\n\n /**\n * Destroy a session and clean up resources.\n */\n protected abstract doDestroySession(session: ThreadSession): Promise<void>;\n}\n","/**\n * MastraBrowser Base Class\n *\n * Abstract base class for browser providers. Extends MastraBase for logger integration.\n *\n * ## Architecture\n *\n * Each browser provider defines its own tools via the `getTools()` method.\n * This allows different providers to offer different capabilities:\n *\n * - **AgentBrowser**: 17 deterministic tools using refs ([ref=e1], [ref=e2])\n * - **StagehandBrowser**: AI-powered tools (act, extract, observe)\n *\n * ## Two Paradigms\n *\n * Browser providers fall into two paradigms:\n *\n * 1. **Deterministic** (Playwright, agent-browser) - Uses refs and selectors\n * 2. **AI-powered** (Stagehand) - Uses natural language instructions\n *\n * Both extend this base class and implement `getTools()` to return their tools.\n */\n\nimport { existsSync, unlinkSync, lstatSync, readdirSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport { MastraBase } from '../base';\nimport { RegisteredLogger } from '../logger/constants';\nimport { isProcessorWorkflow } from '../processors/index';\nimport type { InputProcessor, InputProcessorOrWorkflow } from '../processors/index';\nimport type { Tool } from '../tools/tool';\nimport { createError } from './errors';\nimport type { BrowserToolError, ErrorCode } from './errors';\nimport { BrowserContextProcessor } from './processor';\nimport type { ScreencastOptions as ScreencastOptionsType } from './screencast/types';\nimport { DEFAULT_THREAD_ID } from './thread-manager';\nimport type { BrowserState, BrowserTabState, BrowserScope, ThreadManager } from './thread-manager';\n\n// Re-export screencast types from the screencast module\nexport type { ScreencastOptions, ScreencastFrameData, ScreencastEvents } from './screencast/types';\n\n// Alias for internal use\ntype ScreencastOptions = ScreencastOptionsType;\n\n// =============================================================================\n// Profile Lock File Cleanup\n// =============================================================================\n\n/**\n * Lock files that Chrome/Chromium creates in the profile directory.\n * These can become stale if the browser doesn't shut down cleanly.\n */\nconst CHROME_LOCK_FILES = ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'chrome.pid', 'RunningChromeVersion'];\n\n/**\n * Clean up stale Chrome lock files from a profile directory.\n *\n * Chrome creates lock files (SingletonLock, SingletonSocket, etc.) to prevent\n * multiple instances from using the same profile. If the browser crashes or\n * doesn't shut down cleanly, these files can remain and block future launches.\n *\n * This function removes these lock files, allowing the profile to be reused.\n * It's safe to call even if the files don't exist.\n *\n * @param profilePath - Path to the Chrome profile directory\n * @param logger - Optional logger for debug output\n */\nexport function cleanupProfileLockFiles(\n profilePath: string,\n logger?: { debug?: (message: string) => void; warn?: (message: string) => void },\n): void {\n if (!profilePath || !existsSync(profilePath)) {\n return;\n }\n\n try {\n const entries = readdirSync(profilePath);\n for (const entry of entries) {\n if (CHROME_LOCK_FILES.includes(entry)) {\n const fullPath = join(profilePath, entry);\n try {\n const stat = lstatSync(fullPath);\n // Remove both regular files and symlinks\n if (stat.isFile() || stat.isSymbolicLink()) {\n unlinkSync(fullPath);\n logger?.debug?.(`Removed stale lock file: ${fullPath}`);\n }\n } catch (err) {\n // File may have been removed between readdir and unlink, ignore\n logger?.warn?.(`Failed to remove lock file ${fullPath}: ${err}`);\n }\n }\n }\n } catch (err) {\n // Profile directory may not be readable, ignore\n logger?.warn?.(`Failed to clean up profile lock files in ${profilePath}: ${err}`);\n }\n}\n\n// =============================================================================\n// Process Group Cleanup\n// =============================================================================\n\n/**\n * Kill a browser process and its children by sending SIGKILL to the process group.\n *\n * When Chrome/Chromium is launched, it spawns child processes (GPU, renderer,\n * network, storage, crashpad handlers). If the main process exits uncleanly,\n * these children can become orphaned. Killing the process group ensures all\n * related processes are cleaned up.\n *\n * Note: Process group signaling (`-pid`) is POSIX-only. On Windows, this\n * function is a no-op and orphaned child processes must be cleaned up by\n * other means (e.g., taskkill).\n *\n * @param pid - The PID of the main browser process. If undefined, this is a no-op.\n * @param logger - Optional logger for debug output.\n */\nexport function killProcessGroup(\n pid: number | undefined,\n logger?: { debug?: (message: string) => void; warn?: (message: string) => void },\n): void {\n if (pid == null) return;\n try {\n process.kill(-pid, 'SIGKILL');\n logger?.debug?.(`Killed process group for PID ${pid}`);\n } catch (err) {\n // ESRCH = process already gone — expected\n const code = (err as NodeJS.ErrnoException).code;\n if (code !== 'ESRCH') {\n logger?.warn?.(`Failed to kill process group ${pid}: ${code ?? err}`);\n }\n }\n}\n\n// =============================================================================\n// Status & Lifecycle Types\n// =============================================================================\n\n/**\n * Browser provider status.\n */\nexport type BrowserStatus = 'pending' | 'launching' | 'ready' | 'error' | 'closing' | 'closed';\n\n/**\n * Lifecycle hook that fires during browser state transitions.\n */\nexport type BrowserLifecycleHook = (args: { browser: MastraBrowser }) => void | Promise<void>;\n\n// =============================================================================\n// Configuration Types\n// =============================================================================\n\n/**\n * CDP URL provider - can be a static string or an async function.\n * Useful for cloud providers where the CDP URL may change per session.\n */\nexport type CdpUrlProvider = string | (() => string | Promise<string>);\n\n/**\n * Base configuration properties shared by all browser providers.\n * This interface contains fields common to all browser configurations.\n *\n * **For extending**: Use this interface when creating provider-specific configs\n * (e.g., `interface MyProviderConfig extends BrowserConfigBase`).\n *\n * **For consuming**: Use {@link BrowserConfig} which adds compile-time validation\n * that `cdpUrl` and `scope: 'thread'` cannot be used together.\n */\nexport interface BrowserConfigBase {\n /**\n * Whether to run the browser in headless mode (no visible UI).\n * @default true\n */\n headless?: boolean;\n\n /**\n * Browser viewport dimensions.\n * Controls the size of the browser window and how websites render.\n */\n viewport?: {\n width: number;\n height: number;\n };\n\n /**\n * Default timeout in milliseconds for browser operations.\n * @default 10000 (10 seconds)\n */\n timeout?: number;\n\n /**\n * CDP WebSocket URL or async provider function.\n * When provided, connects to an existing browser instead of launching a new one.\n * Useful for cloud providers (Browserbase, Browserless, Kernel, etc.).\n *\n * **Important:** When using `cdpUrl`, you must use `scope: 'shared'` (or omit `scope`\n * to let it default to 'shared' behavior). Using `cdpUrl` with `scope: 'thread'`\n * will throw an error because thread isolation requires spawning separate browser\n * instances, which isn't possible when connecting to an existing browser via CDP.\n *\n * @example\n * ```ts\n * // Connect to a local Chrome with remote debugging enabled\n * { cdpUrl: 'ws://localhost:9222' }\n *\n * // Connect to Browserless cloud provider\n * { cdpUrl: 'wss://chrome.browserless.io?token=YOUR_TOKEN', scope: 'shared' }\n *\n * // Use an async provider function for dynamic URLs\n * { cdpUrl: async () => await fetchBrowserlessUrl() }\n * ```\n */\n cdpUrl?: CdpUrlProvider;\n\n /**\n * Browser instance scope across threads.\n *\n * - `'thread'` (default): Each thread gets its own isolated browser instance.\n * Best for parallel agents that need separate browser states.\n *\n * - `'shared'`: All threads share a single browser instance.\n * Required when using `cdpUrl` to connect to an existing browser.\n *\n * **Important:** `scope: 'thread'` cannot be used with `cdpUrl` because thread\n * isolation requires spawning new browser instances, which isn't possible when\n * connecting to an existing browser via CDP. This configuration will throw an error.\n *\n * @default 'thread'\n *\n * @example\n * ```ts\n * // Isolated browsers per thread (default)\n * { scope: 'thread' }\n *\n * // Shared browser for all threads\n * { scope: 'shared' }\n *\n * // When using cdpUrl, scope must be 'shared'\n * { cdpUrl: 'ws://localhost:9222', scope: 'shared' }\n * ```\n */\n scope?: BrowserScope;\n\n /**\n * Called after the browser reaches 'ready' status.\n */\n onLaunch?: BrowserLifecycleHook;\n\n /**\n * Called before the browser is closed.\n */\n onClose?: BrowserLifecycleHook;\n\n /**\n * Screencast options for streaming browser frames.\n * Controls image format, quality, and dimensions.\n */\n screencast?: ScreencastOptions;\n\n // ==========================================================================\n // Profile & Authentication Options\n // ==========================================================================\n\n /**\n * Path to a Chrome/Chromium user data directory (profile).\n * When provided, the browser will use this profile's cookies, localStorage,\n * extensions, and other session data.\n *\n * **Important:** Chrome only allows one process to access a profile at a time.\n * If Chrome is already running with this profile, the browser will fail to launch.\n * Either close Chrome first, or use a copy of the profile.\n *\n * @example\n * ```ts\n * // macOS Chrome default profile\n * { profile: '/Users/you/Library/Application Support/Google/Chrome' }\n *\n * // Custom profile directory\n * { profile: '/path/to/my-automation-profile' }\n * ```\n */\n profile?: string;\n\n /**\n * Path to the browser executable to use.\n * By default, Playwright/Stagehand use their bundled Chromium.\n * Use this to launch a specific browser installation instead.\n *\n * @example\n * ```ts\n * // macOS Chrome\n * { executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }\n *\n * // Linux Chrome\n * { executablePath: '/usr/bin/google-chrome' }\n *\n * // Windows Chrome\n * { executablePath: 'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe' }\n * ```\n */\n executablePath?: string;\n}\n\n/**\n * Browser configuration with compile-time enforcement of cdpUrl/scope compatibility.\n *\n * This type enforces that `cdpUrl` and `scope: 'thread'` cannot be used together:\n * - When `cdpUrl` is provided, `scope` must be `'shared'` or omitted\n * - When `scope: 'thread'` is used, `cdpUrl` must not be provided\n *\n * @example\n * ```ts\n * // Valid configurations:\n * { headless: true } // Local browser, thread scope (default)\n * { scope: 'thread' } // Explicit thread isolation\n * { scope: 'shared' } // Shared browser\n * { cdpUrl: 'ws://localhost:9222' } // CDP connection, defaults to shared\n * { cdpUrl: 'ws://localhost:9222', scope: 'shared' } // CDP with explicit shared\n *\n * // Invalid configuration (TypeScript error):\n * { cdpUrl: 'ws://localhost:9222', scope: 'thread' } // Error: cannot combine cdpUrl with thread scope\n * ```\n */\nexport type BrowserConfig =\n | (BrowserConfigBase & { cdpUrl?: undefined; scope?: BrowserScope })\n | (BrowserConfigBase & { cdpUrl: CdpUrlProvider; scope?: 'shared' });\n\n// =============================================================================\n// Screencast Types (re-exported from ./screencast/types)\n// =============================================================================\n\n/**\n * A screencast stream that emits frames.\n * Uses EventEmitter pattern for frame delivery.\n */\nexport interface ScreencastStream {\n /** Stop the screencast */\n stop(): Promise<void>;\n /** Check if screencast is active */\n isActive(): boolean;\n /** Reconnect the screencast (e.g., after tab change) */\n reconnect(): Promise<void>;\n /** Register event handlers */\n on(event: 'frame', handler: (frame: { data: string; viewport: { width: number; height: number } }) => void): this;\n on(event: 'stop', handler: (reason: string) => void): this;\n on(event: 'error', handler: (error: Error) => void): this;\n on(event: 'url', handler: (url: string) => void): this;\n /** Emit a URL update (called by browser providers on navigation) */\n emitUrl(url: string): void;\n}\n\n// =============================================================================\n// Event Injection Types (for Studio live view)\n// =============================================================================\n\n/**\n * Mouse event parameters for CDP injection.\n */\nexport interface MouseEventParams {\n type: 'mousePressed' | 'mouseReleased' | 'mouseMoved' | 'mouseWheel';\n x: number;\n y: number;\n button?: 'left' | 'right' | 'middle' | 'none';\n clickCount?: number;\n deltaX?: number;\n deltaY?: number;\n modifiers?: number;\n}\n\n/**\n * Keyboard event parameters for CDP injection.\n */\nexport interface KeyboardEventParams {\n type: 'keyDown' | 'keyUp' | 'char';\n key?: string;\n code?: string;\n text?: string;\n modifiers?: number;\n /** Windows virtual key code (required for non-printable keys like Enter, Tab, Arrow keys) */\n windowsVirtualKeyCode?: number;\n}\n\n// =============================================================================\n// MastraBrowser Base Class\n// =============================================================================\n\n/**\n * Abstract base class for browser providers.\n *\n * Providers extend this class and implement the abstract methods.\n * Each method corresponds to one of the 17 flat tools.\n */\nexport abstract class MastraBrowser extends MastraBase {\n // ---------------------------------------------------------------------------\n // Abstract Identity (providers must define)\n // ---------------------------------------------------------------------------\n\n /** Unique instance identifier */\n abstract readonly id: string;\n\n /** Human-readable name */\n abstract readonly name: string;\n\n /** Provider identifier (e.g., 'playwright', 'stagehand', 'browserbase') */\n abstract readonly provider: string;\n\n /**\n * Provider type for runtime enforcement.\n * - 'sdk': SDK providers (AgentBrowser, StagehandBrowser) — use with Agent.browser\n * - 'cli': CLI providers (BrowserViewer) — use with Workspace.browser\n * Defaults to 'sdk' for backward compatibility with existing providers.\n */\n readonly providerType: 'sdk' | 'cli' = 'sdk';\n\n // ---------------------------------------------------------------------------\n // State\n // ---------------------------------------------------------------------------\n\n /** Current lifecycle status */\n status: BrowserStatus = 'pending';\n\n /** Error message when status is 'error' */\n error?: string;\n\n /**\n * Whether the browser is running in headless mode.\n * Returns true by default if not explicitly configured.\n */\n get headless(): boolean {\n return this.config.headless ?? true;\n }\n\n /** Last known browser state before browser was closed (for restore on relaunch) */\n protected lastBrowserState?: BrowserState;\n\n /**\n * Shared manager instance for 'shared' scope mode.\n * Type varies by provider (e.g., BrowserManager for agent-browser, Stagehand for stagehand).\n * Providers should cast this to their specific type when accessing.\n */\n protected sharedManager: unknown = null;\n\n /** Configuration */\n protected readonly config: BrowserConfig;\n\n /**\n * Thread manager for handling thread-scoped browser sessions.\n * Set by subclasses that support thread isolation.\n */\n protected threadManager?: ThreadManager;\n\n /**\n * Current thread ID for browser operations.\n * Used by thread isolation to route operations to the correct session.\n */\n protected currentThreadId: string = DEFAULT_THREAD_ID;\n\n // ---------------------------------------------------------------------------\n // Screencast State\n // ---------------------------------------------------------------------------\n\n /** Default key for shared scope screencast streams */\n protected static readonly SHARED_STREAM_KEY = '__shared__';\n\n /** Active screencast streams per thread (for triggering reconnects on tab changes) */\n protected activeScreencastStreams = new Map<string, ScreencastStream>();\n\n // ---------------------------------------------------------------------------\n // Process ID Tracking (for orphaned process cleanup)\n // ---------------------------------------------------------------------------\n\n /**\n * PID of the shared browser process.\n * Set by providers after launch so the base class can kill the process group\n * (GPU, renderer, crashpad, etc.) when the browser disconnects or closes.\n */\n protected sharedBrowserPid?: number;\n\n /**\n * PIDs of per-thread browser processes.\n * Set by providers after creating a thread session.\n */\n protected threadBrowserPids = new Map<string, number>();\n\n /**\n * Get the stream key for a thread (or shared key for shared scope).\n * @param threadId - Optional thread ID\n * @returns The stream key to use for the screencast streams map\n */\n protected getStreamKey(threadId?: string): string {\n return threadId || MastraBrowser.SHARED_STREAM_KEY;\n }\n\n /**\n * Reconnect the active screencast for a specific thread.\n * Called internally when tabs are switched or closed.\n */\n protected async reconnectScreencastForThread(threadId: string | undefined, reason: string): Promise<void> {\n const streamKey = this.getStreamKey(threadId);\n const stream = this.activeScreencastStreams.get(streamKey);\n if (!stream || !stream.isActive()) {\n return;\n }\n\n // Check if browser is still running before attempting reconnect\n if (!this.isBrowserRunning()) {\n this.logger.debug?.('Skipping screencast reconnect - browser not running');\n return;\n }\n\n // For thread scope, also check if this specific thread still has a session\n const scope = this.getScope();\n if (scope === 'thread' && threadId && !this.threadManager?.getExistingManagerForThread(threadId)) {\n this.logger.debug?.(`Skipping screencast reconnect - no session for thread ${threadId}`);\n return;\n }\n\n this.logger.debug?.(`Reconnecting screencast: ${reason}`);\n\n try {\n // Small delay to let tab state settle\n await new Promise(resolve => setTimeout(resolve, 150));\n await stream.reconnect();\n\n // Emit the URL of the new active page after reconnecting\n const activePage = await this.getActivePage(threadId);\n if (activePage) {\n const url = activePage.url();\n if (url) {\n stream.emitUrl(url);\n }\n }\n } catch (error) {\n this.logger.debug?.('Screencast reconnect failed', error);\n }\n }\n\n /**\n * Update the browser state in the thread session.\n * Called on navigation, tab open/close to keep state fresh.\n */\n protected updateSessionBrowserState(threadId?: string): void {\n try {\n const effectiveThreadId = threadId ?? this.getCurrentThread() ?? DEFAULT_THREAD_ID;\n const state = this.getBrowserStateForThrea