Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions benchmarks/page-token-benchmark/config/conditions.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Each condition defines how to fetch a page snapshot.
# tool_mode: cli → runs `<cli_bin> <nav_cmd> <url> [--raw]` as a subprocess
# tool_mode: bridge → calls navigate_page via the bridge HTTP API
# tool_mode: lightpanda → runs `lightpanda fetch --dump <dump> --log-level error <url>` as a subprocess

- id: opera-compact
description: opera-browser-cli compact snapshot, full page (--full)
Expand Down Expand Up @@ -37,3 +38,49 @@
- "chrome-devtools-axi stop"
- "chrome-devtools-axi start"
stop: "chrome-devtools-axi stop"

- id: lightpanda-fetch-markdown
description: lightpanda fetch --dump markdown (one-shot, no CDP/bridge)
tool_mode: lightpanda
cli_bin: lightpanda
dump: markdown

- id: lightpanda-fetch-html
description: lightpanda fetch --dump html (one-shot, raw DOM)
tool_mode: lightpanda
cli_bin: lightpanda
dump: html

- id: lightpanda-fetch-semantic-tree
description: lightpanda fetch --dump semantic_tree_text (pruned plain-text tree)
tool_mode: lightpanda
cli_bin: lightpanda
dump: semantic_tree_text

- id: panda-compact
description: opera-browser-cli with panda backend, compact snapshot, full page (--full)
tool_mode: cli
cli_bin: /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js
nav_cmd: open
raw: false
full: true
env:
OPERA_CLI_BROWSER_BACKEND: panda
OPERA_CLI_LIGHTPANDA_BIN: /Users/opera_user/work/browser/zig-out/bin/lightpanda
start:
- /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js stop
stop: /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js stop

- id: panda-raw
description: opera-browser-cli with panda backend, uncompressed MCP output (--raw --full)
tool_mode: cli
cli_bin: /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js
nav_cmd: open
raw: true
full: true
env:
OPERA_CLI_BROWSER_BACKEND: panda
OPERA_CLI_LIGHTPANDA_BIN: /Users/opera_user/work/browser/zig-out/bin/lightpanda
start:
- /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js stop
stop: /Users/opera_user/work/opera-browser-cli/dist/bin/opera-browser-cli.js stop
48 changes: 48 additions & 0 deletions benchmarks/page-token-benchmark/src/cli_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,46 @@ def run_bridge(url: str, bridge_url: str, timeout: int) -> RunResult:
return RunResult(stdout="", stderr="", returncode=-1, wall_seconds=wall, error=str(exc))


def run_lightpanda_fetch(
url: str,
cli_bin: str,
dump: str,
timeout: int,
) -> RunResult:
"""One-shot Lightpanda fetch: `lightpanda fetch --dump <fmt> --log-level error <url>`.

Dump renders to stdout; logs go to stderr; no server/bridge/CDP involved.
`dump` is one of `markdown | html | semantic_tree | semantic_tree_text`.

Note: Lightpanda `fetch` exits 0 even when navigation fails (it prints
``# Navigation failed`` to stdout), so failure is detected separately.
"""
cmd = [cli_bin, "fetch", "--dump", dump, "--log-level", "error", url]
start = time.monotonic()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
wall = time.monotonic() - start
error = _detect_error(proc.stdout, proc.returncode)
if error is None:
stripped = proc.stdout.lstrip()
if stripped.startswith("# Navigation failed") or "CouldntConnect" in stripped:
error = stripped.splitlines()[0] if stripped.splitlines() else "navigation failed"
return RunResult(
stdout=proc.stdout,
stderr=proc.stderr,
returncode=proc.returncode,
wall_seconds=wall,
error=error,
)
except subprocess.TimeoutExpired:
wall = time.monotonic() - start
return RunResult(stdout="", stderr="", returncode=-1, wall_seconds=wall,
error=f"timeout after {timeout}s: {shlex.join(cmd)}")
except Exception as exc:
wall = time.monotonic() - start
return RunResult(stdout="", stderr="", returncode=-1, wall_seconds=wall, error=str(exc))


def run_condition(url: str, condition: dict, timeout: int = 60) -> RunResult:
"""Dispatch to the right runner based on condition tool_mode."""
mode = condition["tool_mode"]
Expand All @@ -145,4 +185,12 @@ def run_condition(url: str, condition: dict, timeout: int = 60) -> RunResult:
)
if mode == "bridge":
return run_bridge(url, bridge_url=condition.get("bridge_url", "http://localhost:9224"), timeout=timeout)
if mode == "lightpanda":
return run_lightpanda_fetch(
url,
cli_bin=condition.get("cli_bin", "lightpanda"),
dump=condition.get("dump", "markdown"),
timeout=timeout,
)

raise ValueError(f"Unknown tool_mode: {mode}")
8 changes: 8 additions & 0 deletions bin/panda-mcp-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env node
import { main } from "../src/panda-mcp-adapter.js";

main(process.argv.slice(2)).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`[panda-mcp-adapter] Fatal: ${message}\n`);
process.exit(1);
});
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"mcp"
],
"bin": {
"opera-browser-cli": "dist/bin/opera-browser-cli.js"
"opera-browser-cli": "dist/bin/opera-browser-cli.js",
"panda-mcp-adapter": "dist/bin/panda-mcp-adapter.js"
},
"files": [
"dist",
Expand Down
59 changes: 59 additions & 0 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
type BridgeHealth,
} from "./identity.js";
import { getPackageVersion } from "./version.js";
import { detectLightpanda } from "./detect.js";

const DEFAULT_PORT = Number.parseInt(
process.env.OPERA_CLI_PORT ?? "9225",
Expand Down Expand Up @@ -666,6 +667,7 @@ export function shouldRunHeaded(): boolean {
}

export function buildTransportArgs(): string[] {
if (isPandaBackend()) return buildPandaTransportArgs();
const args: string[] = [];

const browserUrl = process.env.OPERA_CLI_BROWSER_URL;
Expand Down Expand Up @@ -713,6 +715,44 @@ export function buildTransportArgs(): string[] {
return args;
}

/** True when the Lightpanda ("panda") backend is selected instead of Chrome/Opera. */
export function isPandaBackend(): boolean {
return process.env.OPERA_CLI_BROWSER_BACKEND === "panda";
}

/**
* Args for the panda adapter shim. Unlike Chrome, there are no browser-launch
* flags to forward — the shim resolves the lightpanda binary itself. We hand it
* the resolved path so `doctor` and the shim agree on the same binary.
*/
function buildPandaTransportArgs(): string[] {
const lightpanda = detectLightpanda();
return lightpanda ? [`--lightpanda-bin=${lightpanda}`] : [];
}

/** Resolve the adapter shim script path (OPERA_CLI_MCP_BIN override, else bundled). */
function resolvePandaAdapterScript(): string {
if (process.env.OPERA_CLI_MCP_BIN) return process.env.OPERA_CLI_MCP_BIN;
const built = resolve(import.meta.dirname, "../bin/panda-mcp-adapter.js");
const source = built.replace(/\.js$/, ".ts");
return existsSync(source) ? source : built;
}

/** Decide how to launch the panda adapter shim, mirroring resolveBridgeLauncher. */
function resolvePandaAdapterLauncher(): BridgeLauncher {
const built = resolve(import.meta.dirname, "../bin/panda-mcp-adapter.js");
const source = built.replace(/\.js$/, ".ts");
const preferSource =
process.env.OPERA_CLI_DEV === "1" || !existsSync(built);
if (preferSource && existsSync(source)) {
const tsx = resolveTsxCli();
if (tsx === null) return { ok: false, reason: "tsx-not-installed" };
return { ok: true, command: process.execPath, args: [tsx, source] };
}
if (!existsSync(built)) return { ok: false, reason: "panda-adapter-not-built" };
return { ok: true, command: process.execPath, args: [built] };
}

export interface McpBinStatus {
bin: string;
found: boolean;
Expand Down Expand Up @@ -742,6 +782,14 @@ function existsOnPath(command: string): boolean {
* bridge start.
*/
export function resolveMcpBinStatus(): McpBinStatus {
if (isPandaBackend()) {
const bin = resolveOperaMcpBin();
return {
bin,
found: existsSync(bin) || existsOnPath(bin),
source: process.env.OPERA_CLI_MCP_BIN ? "env" : "dependency",
};
}
const bin = resolveOperaMcpBin();
if (process.env.OPERA_CLI_MCP_BIN) {
return { bin, found: existsSync(bin) || existsOnPath(bin), source: "env" };
Expand All @@ -753,6 +801,7 @@ export function resolveMcpBinStatus(): McpBinStatus {
}

function resolveOperaMcpBin(): string {
if (isPandaBackend()) return resolvePandaAdapterScript();
if (process.env.OPERA_CLI_MCP_BIN) return process.env.OPERA_CLI_MCP_BIN;
try {
const require = createRequire(import.meta.url);
Expand All @@ -776,6 +825,16 @@ function resolveOperaMcpBin(): string {
}

function createTransport(): StdioClientTransport {
if (isPandaBackend()) {
const launcher = resolvePandaAdapterLauncher();
if (!launcher.ok) {
throw new Error(`Cannot launch the panda adapter: ${launcher.reason}`);
}
return new StdioClientTransport({
command: launcher.command,
args: [...launcher.args, ...buildPandaTransportArgs()],
});
}
const bin = resolveOperaMcpBin();
const args = buildTransportArgs();
if (bin.endsWith(".js")) {
Expand Down
5 changes: 5 additions & 0 deletions src/browser-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export async function launchAttachableBrowser(
executablePath: string | undefined,
userDataDir: string,
extraArgs: string[] = [],
headless = false,
timeoutMs = 30_000,
): Promise<LaunchResult> {
if (!executablePath || !existsSync(executablePath)) {
Expand All @@ -166,6 +167,10 @@ export async function launchAttachableBrowser(
...extraArgs,
];

if (headless) {
args.push("--headless");
}

let child;
try {
child = spawn(executablePath, args, { stdio: "ignore", detached: true });
Expand Down
Loading
Loading