@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
JavaScript
'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