diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 917e049..d3b2b27 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -105,6 +105,11 @@ ${pc.cyan("TRACING")} bap trace --export= Export trace as JSON bap trace --limit= Show last N entries (default: 10) +${pc.cyan("DEBUGGING")} + bap watch Stream live browser events + bap watch --filter=console Filter by event type + bap demo Guided walkthrough for first-time users + ${pc.cyan("CONFIGURATION")} bap config [key] [value] View/set configuration bap install-skill Install skill to all detected agents @@ -147,6 +152,7 @@ const CLIENT_ONLY_COMMANDS = new Set([ "close-all", // tears down everything — don't auto-create "sessions", // informational — just lists contexts "tabs", // informational — just lists pages + "watch", // long-running event stream — don't auto-create browser ]); // ============================================================================= @@ -201,6 +207,7 @@ ${pc.cyan("Quick start:")} ${pc.green("bap act")} fill:e5="hi" click:e12 Multi-step actions ${pc.green("bap trace")} View session history +${pc.dim("Run")} bap demo ${pc.dim("for a guided walkthrough")} ${pc.dim("Run")} bap --help ${pc.dim("for all commands")} `); process.exit(0); diff --git a/packages/cli/src/commands/demo.ts b/packages/cli/src/commands/demo.ts new file mode 100644 index 0000000..1723933 --- /dev/null +++ b/packages/cli/src/commands/demo.ts @@ -0,0 +1,89 @@ +/** + * bap demo — Guided walkthrough of BAP capabilities + * + * Navigates to example.com, observes elements, clicks a link, + * and explains each step for first-time users. + */ + +import { pc } from "@browseragentprotocol/logger"; +import type { BAPClient } from "@browseragentprotocol/client"; +import type { GlobalFlags } from "../config/state.js"; +import { register } from "./registry.js"; + +function step(n: number, text: string): void { + console.log(`\n${pc.cyan(`Step ${n}:`)} ${pc.bold(text)}`); +} + +function explain(text: string): void { + console.log(pc.dim(` ${text}`)); +} + +async function demoCommand(_args: string[], _flags: GlobalFlags, client: BAPClient): Promise { + console.log(`\n${pc.bold("BAP Demo")} ${pc.dim("— see BAP in action")}\n`); + + // Step 1: Navigate + step(1, "Navigate to a page"); + explain("bap goto https://example.com"); + const nav = await client.navigate("https://example.com"); + console.log(` ${pc.green("Loaded:")} ${nav.url} (${nav.status})`); + + // Step 2: Observe + step(2, "Observe interactive elements"); + explain("bap observe"); + const obs = await client.observe({ maxElements: 10 }); + const elements = obs.interactiveElements ?? []; + if (elements.length > 0) { + console.log(` ${pc.green(`Found ${elements.length} element(s):`)}`); + for (const el of elements.slice(0, 5)) { + const ref = el.ref ?? ""; + const role = el.role ?? el.tagName ?? ""; + const name = el.name ?? ""; + console.log(` ${pc.yellow(ref)} ${role}${name ? ` "${name}"` : ""}`); + } + if (elements.length > 5) { + console.log(pc.dim(` ... and ${elements.length - 5} more`)); + } + } else { + console.log(` ${pc.dim("No interactive elements found on this page")}`); + } + + // Step 3: Click a link + const link = elements.find((e: { role: string; name?: string }) => e.role === "link" && e.name); + if (link) { + step(3, `Click a link using its ref`); + explain(`bap click ${link.ref}`); + await client.click({ type: "ref", ref: link.ref! }); + const afterClick = await client.observe({ + includeMetadata: true, + includeInteractiveElements: false, + maxElements: 0, + }); + console.log(` ${pc.green("Navigated to:")} ${afterClick.metadata?.url ?? "unknown"}`); + } else { + step(3, "Click an element"); + console.log(` ${pc.dim("No clickable links found — skipping")}`); + } + + // Step 4: Take a screenshot + step(link ? 4 : 3, "Take a screenshot"); + explain("bap screenshot"); + await client.screenshot(); + console.log(` ${pc.green("Screenshot saved to .bap/ directory")}`); + + // Summary + console.log(` +${pc.bold("What you just saw:")} + ${pc.cyan("goto")} Navigate to any URL + ${pc.cyan("observe")} See interactive elements with refs (${pc.yellow("e1")}, ${pc.yellow("e2")}, ...) + ${pc.cyan("click")} Click elements using refs or semantic selectors + ${pc.cyan("screenshot")} Capture the page + +${pc.bold("Try next:")} + ${pc.green("bap goto")} ${pc.dim("--observe")} + ${pc.green("bap act")} fill:e5="hello" click:e12 + ${pc.green("bap extract")} --fields="title,content" + ${pc.green("bap --help")} ${pc.dim("for all commands")} +`); +} + +register("demo", demoCommand); diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index e0c41ab..eeceddc 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -32,3 +32,5 @@ import "./config.js"; import "./recipe.js"; import "./install-skill.js"; import "./trace.js"; +import "./demo.js"; +import "./watch.js"; diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts new file mode 100644 index 0000000..f13040e --- /dev/null +++ b/packages/cli/src/commands/watch.ts @@ -0,0 +1,172 @@ +/** + * bap watch — Stream live browser events to terminal + * + * Long-running command that subscribes to page, console, network, + * dialog, and download events. Runs until Ctrl+C. + * + * Usage: + * bap watch All events + * bap watch --filter=console,network Only console and network + * bap watch --format=json JSON output for piping + */ + +import { pc } from "@browseragentprotocol/logger"; +import type { BAPClient } from "@browseragentprotocol/client"; +import type { + PageEvent, + ConsoleEvent, + NetworkEvent, + DialogEvent, + DownloadEvent, +} from "@browseragentprotocol/protocol"; +import type { GlobalFlags } from "../config/state.js"; +import { getOutputFormat } from "../output/formatter.js"; +import { register } from "./registry.js"; + +function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString("en-US", { hour12: false }); +} + +function levelColor(level: string): (s: string) => string { + switch (level) { + case "error": + return pc.red; + case "warn": + return pc.yellow; + case "debug": + return pc.dim; + default: + return (s: string) => s; + } +} + +function statusColor(status: number): (s: string) => string { + if (status >= 400) return pc.red; + if (status >= 300) return pc.yellow; + return pc.green; +} + +function formatPageEvent(event: PageEvent): string { + const time = formatTime(event.timestamp); + return `${pc.dim(time)} ${pc.cyan("PAGE")} ${event.type}${event.url ? ` ${event.url}` : ""}`; +} + +function formatConsoleEvent(event: ConsoleEvent): string { + const time = formatTime(event.timestamp); + const colorFn = levelColor(event.level); + const label = colorFn(event.level.toUpperCase().padEnd(5)); + const text = event.text.length > 200 ? event.text.slice(0, 200) + "..." : event.text; + return `${pc.dim(time)} ${pc.magenta("CONSOLE")} ${label} ${text}`; +} + +function formatNetworkEvent(event: NetworkEvent): string { + const time = formatTime(event.timestamp); + if (event.type === "request") { + const method = event.method.padEnd(4); + return `${pc.dim(time)} ${pc.blue("NET")} ${pc.dim("→")} ${method} ${event.url}`; + } else if (event.type === "response") { + const colorFn = statusColor(event.status); + return `${pc.dim(time)} ${pc.blue("NET")} ${pc.dim("←")} ${colorFn(String(event.status))} ${event.url}`; + } else { + return `${pc.dim(time)} ${pc.red("NET")} ${pc.red("✗")} ${event.url} ${pc.dim(event.error)}`; + } +} + +function formatDialogEvent(event: DialogEvent): string { + const time = formatTime(event.timestamp); + return `${pc.dim(time)} ${pc.yellow("DIALOG")} ${event.type}: ${event.message}`; +} + +function formatDownloadEvent(event: DownloadEvent): string { + const time = formatTime(event.timestamp); + return `${pc.dim(time)} ${pc.green("DOWNLOAD")} ${event.state} ${event.suggestedFilename ?? event.url}`; +} + +async function watchCommand(args: string[], _flags: GlobalFlags, client: BAPClient): Promise { + const format = getOutputFormat(); + const isJson = format === "json"; + + // Parse --filter flag from args + const filterArg = args.find((a) => a.startsWith("--filter=")); + const allowedTypes = filterArg ? new Set(filterArg.slice("--filter=".length).split(",")) : null; + + const shouldShow = (type: string): boolean => !allowedTypes || allowedTypes.has(type); + + if (!isJson) { + const filterLabel = allowedTypes ? ` (${[...allowedTypes].join(", ")})` : ""; + console.log(`${pc.bold("Watching browser events")}${pc.dim(filterLabel)}`); + console.log(pc.dim("Press Ctrl+C to stop\n")); + } + + // Subscribe to events + if (shouldShow("page")) { + client.on("page", (event: PageEvent) => { + if (isJson) { + console.log(JSON.stringify({ eventType: "page", ...event })); + } else { + console.log(formatPageEvent(event)); + } + }); + } + + if (shouldShow("console")) { + client.on("console", (event: ConsoleEvent) => { + if (isJson) { + console.log(JSON.stringify({ eventType: "console", ...event })); + } else { + console.log(formatConsoleEvent(event)); + } + }); + } + + if (shouldShow("network")) { + client.on("network", (event: NetworkEvent) => { + if (isJson) { + console.log(JSON.stringify({ eventType: "network", ...event })); + } else { + console.log(formatNetworkEvent(event)); + } + }); + } + + if (shouldShow("dialog")) { + client.on("dialog", (event: DialogEvent) => { + if (isJson) { + console.log(JSON.stringify({ eventType: "dialog", ...event })); + } else { + console.log(formatDialogEvent(event)); + } + }); + } + + if (shouldShow("download")) { + client.on("download", (event: DownloadEvent) => { + if (isJson) { + console.log(JSON.stringify({ eventType: "download", ...event })); + } else { + console.log(formatDownloadEvent(event)); + } + }); + } + + // Keep the process alive until interrupted + await new Promise((resolve) => { + let resolved = false; + const cleanup = () => { + if (resolved) return; + resolved = true; + process.off("SIGINT", cleanup); + process.off("SIGTERM", cleanup); + client.off("close", cleanup); + if (!isJson) { + console.log(pc.dim("\nStopped watching.")); + } + resolve(); + }; + process.once("SIGINT", cleanup); + process.once("SIGTERM", cleanup); + client.on("close", cleanup); + }); +} + +register("watch", watchCommand); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 35144f4..4abf6a9 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -66,6 +66,14 @@ "@modelcontextprotocol/sdk": "^1.27.1", "zod": "^3.23.0" }, + "peerDependencies": { + "@browseragentprotocol/server-playwright": "workspace:*" + }, + "peerDependenciesMeta": { + "@browseragentprotocol/server-playwright": { + "optional": true + } + }, "devDependencies": { "tsup": "^8.3.0", "typescript": "^5.7.0" diff --git a/packages/mcp/src/cli.ts b/packages/mcp/src/cli.ts index fe2d017..6e03133 100644 --- a/packages/mcp/src/cli.ts +++ b/packages/mcp/src/cli.ts @@ -37,6 +37,7 @@ interface CLIArgs { headless?: boolean; allowedDomains?: string[]; slim?: boolean; + inProcess?: boolean; help?: boolean; version?: boolean; } @@ -70,6 +71,8 @@ function parseArgs(): CLIArgs { args.allowedDomains = argv[++i]?.split(",").map((d) => d.trim()); } else if (arg === "--slim") { args.slim = true; + } else if (arg === "--in-process") { + args.inProcess = true; } } @@ -94,6 +97,8 @@ ${pc.cyan("OPTIONS")} ${pc.yellow("--no-headless")} Run with visible browser window ${pc.yellow("-v, --verbose")} Enable verbose logging to stderr ${pc.yellow("--allowed-domains")} ${pc.dim("")} Comma-separated list of allowed domains + ${pc.yellow("--in-process")} Run Playwright server in same process ${pc.dim("(no WebSocket)")} + ${pc.yellow("--slim")} Expose only 5 essential tools ${pc.yellow("-h, --help")} Show this help message ${pc.yellow("--version")} Show version @@ -324,7 +329,7 @@ async function main(): Promise { } if (args.version) { - console.error(`${icons.connection} BAP MCP Server ${pc.dim("v0.6.0")}`); + console.error(`${icons.connection} BAP MCP Server ${pc.dim("v0.8.0")}`); process.exit(0); } @@ -332,15 +337,20 @@ async function main(): Promise { log.setLevel("debug"); } - // Determine mode: standalone (auto-start server) vs connect to existing - const isStandalone = !args.url; + // Determine mode: in-process vs standalone (auto-start server) vs connect to existing + const isInProcess = args.inProcess ?? false; + const isStandalone = !args.url && !isInProcess; const port = args.port ?? 9222; const host = "localhost"; const bapServerUrl = args.url ?? `ws://${host}:${port}`; let serverProcess: ChildProcess | null = null; try { - if (isStandalone) { + if (isInProcess) { + if (args.verbose) { + log.info("In-process mode: running Playwright server in same process"); + } + } else if (isStandalone) { if (args.verbose) { log.info("Standalone mode: auto-starting BAP Playwright server"); } @@ -361,6 +371,7 @@ async function main(): Promise { verbose: args.verbose, allowedDomains: args.allowedDomains, slim: args.slim, + inProcess: isInProcess, }); // Graceful shutdown — clean up MCP server and child process diff --git a/packages/mcp/src/direct-transport.ts b/packages/mcp/src/direct-transport.ts new file mode 100644 index 0000000..4937d66 --- /dev/null +++ b/packages/mcp/src/direct-transport.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview In-process transport that bypasses WebSocket + * @module @browseragentprotocol/mcp/direct-transport + * + * Used with `--in-process` mode. Routes JSON-RPC messages directly + * through BAPPlaywrightServer.createInProcessClient() without + * serialization over the network. + */ + +import type { BAPTransport } from "@browseragentprotocol/client"; + +/** + * DirectTransport implements BAPTransport by calling a request handler + * function directly instead of sending over WebSocket. + * + * Note: Server-push notifications (events) are not supported in this + * transport — event streaming requires WebSocket. This is a known + * limitation of --in-process mode. + */ +export class DirectTransport implements BAPTransport { + private handler: ((message: string) => Promise) | null; + private closeHandler: (() => Promise) | null; + private closed = false; + + onMessage: ((message: string) => void) | null = null; + onClose: (() => void) | null = null; + onError: ((error: Error) => void) | null = null; + + constructor(handler: (message: string) => Promise, closeHandler: () => Promise) { + this.handler = handler; + this.closeHandler = closeHandler; + } + + async send(message: string): Promise { + if (!this.handler) { + throw new Error("DirectTransport is closed"); + } + try { + const response = await this.handler(message); + if (this.onMessage) { + this.onMessage(response); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + if (this.onError) { + this.onError(err); + } else { + throw err; + } + } + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + if (this.closeHandler) { + await this.closeHandler(); + this.closeHandler = null; + } + this.handler = null; + if (this.onClose) { + this.onClose(); + } + } +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 8b28c00..570f2e2 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -26,6 +26,7 @@ import { import { BAPClient, WebSocketTransport, + type BAPTransport, type BAPSelector, type WaitUntilState, type ContentFormat, @@ -33,6 +34,7 @@ import { type ExecutionStep, type ElementProperty, } from "@browseragentprotocol/client"; +import { DirectTransport } from "./direct-transport.js"; import { type StepResult, type InteractiveElement, @@ -149,6 +151,8 @@ export interface BAPMCPServerOptions { maxSessionDuration?: number; /** Slim mode: expose only 5 essential tools (navigate, observe, act, extract, screenshot) */ slim?: boolean; + /** In-process mode: run Playwright server in same process, bypass WebSocket */ + inProcess?: boolean; } interface ToolResult { @@ -870,7 +874,8 @@ function resolveBrowser(browser: BrowserChoice): { export class BAPMCPServer { private server: Server; private client: BAPClient | null = null; - private transport: WebSocketTransport | null = null; + private transport: WebSocketTransport | BAPTransport | null = null; + private inProcessServer: unknown = null; // BAPPlaywrightServer, dynamically imported private options: Required; constructor(options: BAPMCPServerOptions = {}) { @@ -884,6 +889,7 @@ export class BAPMCPServer { allowedDomains: options.allowedDomains ?? [], maxSessionDuration: options.maxSessionDuration ?? 3600, slim: options.slim ?? false, + inProcess: options.inProcess ?? false, }; this.server = new Server( @@ -952,26 +958,40 @@ export class BAPMCPServer { } } - this.log("Connecting to BAP server:", this.options.bapServerUrl); - - this.transport = new WebSocketTransport(this.options.bapServerUrl, { - autoReconnect: true, - maxReconnectAttempts: 5, - reconnectDelay: 1000, - }); - - // Hook reconnect callbacks for verbose logging - this.transport.onReconnecting = (attempt, max) => { - this.log(`Reconnecting to BAP server (attempt ${attempt}/${max})...`); - }; - this.transport.onReconnected = () => { - this.log("Reconnected to BAP server"); - }; - this.transport.onClose = () => { - this.log("BAP server connection closed"); - }; + if (this.options.inProcess) { + this.log("Starting in-process BAP server..."); + // Dynamic import to avoid hard dependency on server-playwright + const { BAPPlaywrightServer } = await import("@browseragentprotocol/server-playwright"); + const server = new BAPPlaywrightServer({ + debug: this.options.verbose, + }); + this.inProcessServer = server; + const handle = server.createInProcessClient(); + this.transport = new DirectTransport(handle.request, handle.close); + this.client = new BAPClient(this.transport); + } else { + this.log("Connecting to BAP server:", this.options.bapServerUrl); + + const wsTransport = new WebSocketTransport(this.options.bapServerUrl, { + autoReconnect: true, + maxReconnectAttempts: 5, + reconnectDelay: 1000, + }); - this.client = new BAPClient(this.transport); + // Hook reconnect callbacks for verbose logging + wsTransport.onReconnecting = (attempt, max) => { + this.log(`Reconnecting to BAP server (attempt ${attempt}/${max})...`); + }; + wsTransport.onReconnected = () => { + this.log("Reconnected to BAP server"); + }; + wsTransport.onClose = () => { + this.log("BAP server connection closed"); + }; + + this.transport = wsTransport; + this.client = new BAPClient(this.transport); + } // Connect and initialize the protocol await this.client.connect(); @@ -1022,6 +1042,15 @@ export class BAPMCPServer { } catch { /* ignore cleanup errors */ } + // Stop in-process server to avoid leaking browser processes on reconnect + if (this.inProcessServer) { + try { + await (this.inProcessServer as { stop: () => Promise }).stop(); + } catch { + /* ignore cleanup errors */ + } + this.inProcessServer = null; + } this.client = null; this.transport = null; } @@ -1798,6 +1827,15 @@ export class BAPMCPServer { */ async close(): Promise { await this.resetClient(); + // Stop in-process Playwright server if running + if (this.inProcessServer) { + try { + await (this.inProcessServer as { stop: () => Promise }).stop(); + } catch { + /* best effort */ + } + this.inProcessServer = null; + } await this.server.close(); this.log("BAP MCP Server closed"); } diff --git a/packages/server-playwright/src/server.ts b/packages/server-playwright/src/server.ts index d046744..a5bfde7 100644 --- a/packages/server-playwright/src/server.ts +++ b/packages/server-playwright/src/server.ts @@ -352,6 +352,88 @@ export class BAPPlaywrightServer extends EventEmitter { } } + // =========================================================================== + // In-Process Client (for --in-process MCP mode) + // =========================================================================== + + /** + * Create an in-process client that bypasses WebSocket. + * Returns a handle with `request()` for sending JSON-RPC requests + * and `close()` for cleanup. + * + * Note: Server-push notifications (events) are not supported in + * in-process mode. Event streaming requires WebSocket transport. + */ + createInProcessClient(options?: { + sessionId?: string; + onNotification?: (message: string) => void; + }): { + request: (message: string) => Promise; + close: () => Promise; + state: ClientState; + } { + const now = Date.now(); + const state: ClientState = { + clientId: randomUUID().slice(0, 8), + initialized: false, + browser: null, + isPersistent: false, + browserOwnership: "owned", + context: null, + contexts: new Map(), + defaultContextId: null, + pages: new Map(), + pageToContext: new Map(), + activePage: null, + eventSubscriptions: new Set(), + tracing: false, + scopes: this.getClientScopes(), + sessionStartTime: now, + lastActivityTime: now, + elementRegistries: new Map(), + frameContexts: new Map(), + activeStreams: new Map(), + pendingApprovals: new Map(), + sessionApprovals: new Set(), + sessionId: options?.sessionId, + }; + + // Use a sentinel key for in-process clients (no real WebSocket) + const sentinelWs = null; + + this.log("In-process client created", { clientId: state.clientId }); + + const request = async (message: string): Promise => { + let parsed: JSONRPCRequest; + try { + parsed = JSON.parse(message) as JSONRPCRequest; + } catch { + return JSON.stringify(createErrorResponse(0, ErrorCodes.ParseError, "Invalid JSON")); + } + if (!isRequest(parsed)) { + return JSON.stringify( + createErrorResponse(0, ErrorCodes.ParseError, "Invalid JSON-RPC request") + ); + } + const response = await this.handleRequest(sentinelWs as unknown as WebSocket, state, parsed); + return JSON.stringify(response); + }; + + const close = async (): Promise => { + this.log("In-process client disconnecting", { clientId: state.clientId }); + const isAlive = state.isPersistent + ? this.isContextAlive(state.context) + : Boolean(state.browser?.isConnected()); + if (state.sessionId && isAlive) { + await _parkSession(state, this.getDormantStoreDeps()); + } else { + await this.cleanupClient(state); + } + }; + + return { request, close, state }; + } + // =========================================================================== // Server Start / Stop // ===========================================================================