@mastra/core
Version:
1,353 lines (1,346 loc) • 85.3 kB
JavaScript
export { BrowserCliHandler, browserCliHandler } from '../chunk-OE4IEL7C.js';
import { isProcessorWorkflow } from '../chunk-ZWZWJC3R.js';
import { createTool } from '../chunk-O5QBSZXE.js';
import { MastraBase } from '../chunk-77VL4DNS.js';
import { RegisteredLogger } from '../chunk-O274XICP.js';
import { existsSync, readdirSync, lstatSync, unlinkSync, mkdirSync, openSync, closeSync, writeSync, fstatSync } from 'fs';
import { join, resolve, relative, isAbsolute, sep, dirname } from 'path';
import { randomUUID } from 'crypto';
import { EventEmitter } from 'events';
import { z } from 'zod';
import { decode, encode } from 'jpeg-js';
// 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)
};
}
var BROWSER_PROCESS_ID = randomUUID();
var BrowserContextProcessor = class {
id = "browser-context";
stateId = "browser";
processInput(args) {
const ctx = args.requestContext?.get("browser");
if (!ctx) return args.messageList;
const lines = [
`You have access to a browser (${ctx.provider}).`,
'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.'
];
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 computeStateSignal(args) {
const ctx = args.requestContext?.get("browser");
if (!ctx) return;
const refreshedState = await ctx.getState?.();
let browserState = getBrowserState(refreshedState ? { ...ctx, ...refreshedState } : ctx);
const shouldRefreshSnapshot = Boolean(args.lastSnapshot && !args.contextWindow.hasSnapshot);
const previousState = getMostRecentBrowserState(args.activeStateSignals) ?? getBrowserStateFromSignal(args.lastSnapshot);
if (!browserState.open && !browserState.closeReason) {
if (!previousState?.open || !previousState.processId) {
if (isBareClosedState(browserState)) return;
} else {
browserState = {
...browserState,
closeReason: previousState.processId === browserState.processId ? "user" : "process_restart"
};
}
}
if (previousState?.open && browserState.open && previousState.activeUrl && browserState.activeUrl && previousState.activeUrl !== browserState.activeUrl && previousState.processId === browserState.processId && browserState.activeUrlChangeSource !== "agent") {
const recentClickUrl = getMostRecentBrowserClickResultUrl(args.steps) ?? getMostRecentBrowserClickResultUrlFromMessageList(args.messageList);
const activeUrlChangeSource = recentClickUrl === browserState.activeUrl ? "agent" : browserState.activeUrlChangeSource ?? "user";
browserState = {
...browserState,
activeUrlChangeSource
};
}
const changed = getChangedBrowserState(previousState, browserState);
if (previousState && Object.keys(changed).length === 0 && !shouldRefreshSnapshot) return;
const isDelta = Boolean(previousState && !shouldRefreshSnapshot);
const result = {
id: "browser",
cacheKey: stableBrowserStateCacheKey(browserState),
mode: isDelta ? "delta" : "snapshot",
tagName: "state",
contents: isDelta ? formatBrowserStateDelta(changed) : formatBrowserStateSnapshot(browserState),
value: browserState,
...isDelta ? { delta: changed } : {},
attributes: {
type: "browser",
updated: (/* @__PURE__ */ new Date()).toISOString()
},
metadata: {
browser: browserState
}
};
return result;
}
};
function getBrowserState(ctx) {
return {
processId: BROWSER_PROCESS_ID,
open: ctx.isOpen ?? true,
...ctx.currentUrl ? { activeUrl: ctx.currentUrl } : {},
...ctx.pageTitle ? { pageTitle: ctx.pageTitle } : {},
...typeof ctx.tabCount === "number" ? { tabCount: ctx.tabCount } : {},
...ctx.activeUrlChangeSource ? { activeUrlChangeSource: ctx.activeUrlChangeSource } : {},
...ctx.pageMetadata ? { pageMetadata: ctx.pageMetadata } : {},
...ctx.closeReason ? { closeReason: ctx.closeReason } : {}
};
}
function getMostRecentBrowserState(activeStateSignals) {
for (const signal of [...activeStateSignals].reverse()) {
const browserState = getBrowserStateFromSignal(signal);
if (browserState) return browserState;
}
return void 0;
}
function getMostRecentBrowserClickResultUrl(steps) {
for (const step of [...steps].reverse()) {
const toolResults = Array.isArray(step.toolResults) ? step.toolResults : [];
for (const toolResult of [...toolResults].reverse()) {
if (toolResult?.toolName !== "browser_click") continue;
const url = getBrowserClickResultUrl(getToolResultOutputForAttribution(toolResult));
if (url) return url;
}
}
return void 0;
}
function getMostRecentBrowserClickResultUrlFromMessageList(messageList) {
try {
return getMostRecentBrowserClickResultUrlFromMessages(messageList.get.all.db());
} catch {
return void 0;
}
}
function getMostRecentBrowserClickResultUrlFromMessages(messages) {
for (const message of [...messages].reverse()) {
if (isBrowserStateSignalMessage(message)) return void 0;
const content = message.content;
if (!content || typeof content !== "object" || Array.isArray(content)) continue;
const parts = content.parts;
if (!Array.isArray(parts)) continue;
for (const part of [...parts].reverse()) {
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
const toolInvocation = part.toolInvocation;
if (!toolInvocation || typeof toolInvocation !== "object" || Array.isArray(toolInvocation)) continue;
const invocation = toolInvocation;
if (invocation.toolName !== "browser_click" || invocation.state !== "result") continue;
const url = getBrowserClickResultUrl(invocation.result ?? invocation.output);
if (url) return url;
}
}
return void 0;
}
function isBrowserStateSignalMessage(message) {
const content = message.content;
if (!content || typeof content !== "object" || Array.isArray(content)) return false;
const signal = content.metadata?.signal;
if (!signal || typeof signal !== "object" || Array.isArray(signal)) return false;
const metadata = signal.metadata;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return false;
const state = metadata.state;
if (!state || typeof state !== "object" || Array.isArray(state)) return false;
return state.id === "browser";
}
function getBrowserClickResultUrl(output) {
if (!output || typeof output !== "object" || Array.isArray(output)) return void 0;
if (output.success !== true) return void 0;
const url = output.url;
return typeof url === "string" && url.length > 0 ? url : void 0;
}
function getToolResultOutputForAttribution(toolResult) {
if (!toolResult || typeof toolResult !== "object" || Array.isArray(toolResult)) return void 0;
const record = toolResult;
return record.output ?? record.result;
}
function getBrowserStateFromSignal(signal) {
const value = signal?.metadata?.value;
if (value && typeof value === "object" && !Array.isArray(value)) {
return value;
}
const browser = signal?.metadata?.browser;
if (browser && typeof browser === "object" && !Array.isArray(browser)) {
return browser;
}
return void 0;
}
function stableBrowserStateCacheKey(state) {
return JSON.stringify(state, (_key, value) => {
if (value && typeof value === "object" && !Array.isArray(value)) {
const sorted = {};
for (const key of Object.keys(value).sort()) {
sorted[key] = value[key];
}
return sorted;
}
return value;
});
}
function isBareClosedState(state) {
return !state.open && !state.activeUrl && !state.pageTitle && typeof state.tabCount !== "number" && !state.closeReason && (!state.pageMetadata || Object.keys(state.pageMetadata).length === 0);
}
function getChangedBrowserState(previous, current) {
if (!previous) return current;
const changed = {};
for (const key of Object.keys(current)) {
if (key === "processId") continue;
if (key === "activeUrlChangeSource" && previous.activeUrl === current.activeUrl) continue;
if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) {
changed[key] = current[key];
}
}
if (previous.activeUrl !== current.activeUrl && current.activeUrlChangeSource) {
changed.activeUrlChangeSource = current.activeUrlChangeSource;
}
if (!previous.open && current.open) {
if (current.activeUrl) changed.activeUrl = current.activeUrl;
if (current.activeUrlChangeSource) changed.activeUrlChangeSource = current.activeUrlChangeSource;
if (current.pageTitle) changed.pageTitle = current.pageTitle;
if (typeof current.tabCount === "number") changed.tabCount = current.tabCount;
if (current.pageMetadata && Object.keys(current.pageMetadata).length > 0)
changed.pageMetadata = current.pageMetadata;
}
return changed;
}
function formatBrowserStateSnapshot(state) {
const parts = [formatOpenClosedStatus(state)];
if (state.activeUrl) parts.push(`Active tab URL: ${state.activeUrl}.`);
if (state.pageTitle) parts.push(`Page title: ${state.pageTitle}.`);
if (typeof state.tabCount === "number")
parts.push(`${state.tabCount} open ${state.tabCount === 1 ? "tab" : "tabs"}.`);
if (state.pageMetadata && Object.keys(state.pageMetadata).length > 0) {
parts.push(`Page metadata: ${JSON.stringify(state.pageMetadata)}.`);
}
return parts.join(" ");
}
function formatBrowserStateDelta(delta) {
const parts = [];
if (typeof delta.open === "boolean") {
if (delta.open) {
parts.push("browser opened");
} else {
parts.push(formatCloseReason(delta.closeReason));
}
}
if (delta.activeUrl) parts.push(formatActiveUrlChange(delta.activeUrl, delta.activeUrlChangeSource));
if (delta.pageTitle) parts.push(`page title changed to ${delta.pageTitle}`);
if (typeof delta.tabCount === "number") parts.push(`${delta.tabCount} open ${delta.tabCount === 1 ? "tab" : "tabs"}`);
if (delta.pageMetadata && Object.keys(delta.pageMetadata).length > 0) {
parts.push(`page metadata changed to ${JSON.stringify(delta.pageMetadata)}`);
}
return `changed: ${parts.join("; ")}`;
}
function formatActiveUrlChange(url, source) {
switch (source) {
case "agent":
return `agent changed active tab URL to ${url}`;
case "user":
return `user changed active tab URL to ${url}`;
default:
return `active tab URL changed to ${url}`;
}
}
function formatOpenClosedStatus(state) {
if (state.open) return "Browser is open.";
switch (state.closeReason) {
case "process_restart":
return "The browser was closed because the chat process restarted.";
case "user":
return "The browser was closed externally, maybe by the user.";
case "error":
return "Browser closed unexpectedly due to an error.";
case "agent":
return "Browser is closed.";
default:
return "Browser is closed.";
}
}
function formatCloseReason(reason) {
switch (reason) {
case "process_restart":
return "the browser was closed because the chat process restarted";
case "user":
return "the browser was closed externally, maybe by the user";
case "error":
return "browser closed unexpectedly due to an error";
case "agent":
return "browser closed";
default:
return "browser closed";
}
}
// 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 = {
...state,
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 || !existsSync(profilePath)) {
return;
}
try {
const entries = readdirSync(profilePath);
for (const entry of entries) {
if (CHROME_LOCK_FILES.includes(entry)) {
const fullPath = join(profilePath, entry);
try {
const stat = lstatSync(fullPath);
if (stat.isFile() || stat.isSymbolicLink()) {
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 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((resolve2) => setTimeout(resolve2, 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: 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 ev