Jev Browser — headless browser use, one Jev judgment per step
jev-browser drives a real headless Chromium and asks Jev to pick the next action, one call per step.
Three judgments over the same state, deliberately unable to see each other: a Choice over the page's clickable, typeable and selectable elements — which single action best advances the task — and two Nouls, whether the goal has been achieved and whether the run is stuck repeating itself. The fan-out is the point. Because goal_done cannot see the action the Choice picked, it is an honest cross-check rather than a rationalisation of it.
A native <select> adds a second-stage Choice over that element's option labels, asked only once the first stage has chosen it — Choice cardinality is bounded, and the options are not known until then.
Code owns the loop. Budgets, recovery and stop gates stay outside the model; what comes back is the final page, a step trace with per-step confidences, console errors and a screenshot.
Navigating Wikipedia from Coffee to Espresso took about four seconds and $0.0016.
Source (8 files)
// Downloads Chromium for Playwright unless it is already present or the
// caller opted out. Keeps `npx -y github:jkudish/jev-browser` self-contained:
// "packages everything it needs to navigate directly".
import { execFileSync } from "node:child_process";
if (process.env.JEV_BROWSER_SKIP_BROWSER_DOWNLOAD === "1" || process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === "1") {
process.exit(0);
}
try {
execFileSync("npx", ["--yes", "playwright", "install", "chromium"], {
stdio: "inherit",
env: process.env,
});
} catch (error) {
console.error("[jev-browser] chromium install failed:", error.message);
console.error("[jev-browser] retry manually with: npx playwright install chromium");
process.exit(0); // not fatal: an existing system install may still work
}
// CLI: `jev-browser run "<task>" <start-url> [options]`
// Everything else (no args) starts the MCP stdio server (src/index.ts).
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { navigate, type NavigateOptions } from "./navigate.js";
interface CliArgs extends NavigateOptions {
screenshotPath?: string;
recordPath?: string;
help?: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = { task: undefined as unknown as string, startUrl: undefined as unknown as string };
const positional: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "--format":
args.format = argv[++i] as CliArgs["format"];
break;
case "--max-chars":
args.maxChars = Number(argv[++i]);
break;
case "--max-steps":
args.maxSteps = Number(argv[++i]);
break;
case "--max-seconds":
args.maxSeconds = Number(argv[++i]);
break;
case "--no-typing":
args.allowTyping = false;
break;
case "--screenshot":
args.screenshotPath = argv[++i];
break;
case "--no-screenshot":
args.screenshot = "none";
break;
case "--record":
args.recordPath = argv[++i];
break;
case "--help":
case "-h":
args.help = true;
break;
default:
positional.push(arg);
}
}
const [task, startUrl] = positional;
return { ...args, task, startUrl };
}
const HELP = `jev-browser run "<task>" <start-url> [options]
Options:
--format <text|markdown|html|aria> Final page payload (default text)
--max-chars <n> Override the format's character cap
--max-steps <n> Hard step cap (default 24)
--max-seconds <n> Wall-clock cap (default 180)
--no-typing Disable typing into fields
--screenshot <path> Write the final JPEG to this path
--no-screenshot Skip the screenshot entirely
--record <path> Record a video of the page; a .webm path
saves to that file, any other value is a
directory for Playwright's output
-h, --help Show this help
Result JSON is printed to stdout. Environment: TYPESAFE_API_KEY required;
JEV_BROWSER_* vars configure models and the typing provider.
Without "run", this binary starts the MCP stdio server.`;
export async function runCli(argv: string[]): Promise<number> {
const args = parseArgs(argv);
if (args.help || !args.task || !args.startUrl) {
console.log(HELP);
return args.help ? 0 : 1;
}
const { screenshotPath, recordPath, ...navigateArgs } = args;
let recordDir: string | undefined;
if (recordPath) {
recordDir = recordPath.endsWith(".webm") ? await import("node:fs/promises").then((fs) => fs.mkdtemp("jev-browser-record-")) : recordPath;
}
const result = (await navigate({
...navigateArgs,
screenshot: screenshotPath ? "final" : (args.screenshot ?? "final"),
recordDir,
})) as Record<string, any>;
if (recordPath?.endsWith(".webm") && result.video_path) {
const fs = await import("node:fs/promises");
// Playwright can flush the video for a moment after close; wait for the
// source file to settle before copying, or the copy truncates.
let size = -1;
for (let i = 0; i < 20; i++) {
const stat = await fs.stat(result.video_path).catch(() => null);
const current = stat?.size ?? -1;
if (current === size && current > 0) break;
size = current;
await new Promise((r) => setTimeout(r, 500));
}
await fs.copyFile(result.video_path, recordPath);
result.video_path = recordPath;
}
if (screenshotPath && result.screenshot_base64_jpeg) {
await mkdir(dirname(screenshotPath), { recursive: true });
await writeFile(screenshotPath, Buffer.from(result.screenshot_base64_jpeg, "base64"));
result.screenshot_path = screenshotPath;
}
// The CLI prints JSON; base64 screenshots belong in files, not terminals.
delete result.screenshot_base64_jpeg;
console.log(JSON.stringify(result, null, 2));
return result.status === "error" ? 1 : 0;
}
#!/usr/bin/env node
// jev-browser: a Jev-driven browser agent.
// jev-browser run "<task>" <url> [options] CLI
// jev-browser MCP stdio server
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { navigate } from "./navigate.js";
import { runCli } from "./cli.js";
if (process.argv[2] === "run") {
process.exit(await runCli(process.argv.slice(3)));
}
if (process.argv[2] === "--help" || process.argv[2] === "-h") {
process.exit(await runCli(["--help"]));
}
const server = new McpServer({ name: "jev-browser", version: "0.1.0" });
server.registerTool(
"jev_navigate",
{
title: "Navigate a browser with Jev",
description:
"Give a task and a start URL; a Jev-driven agent navigates a real headless browser until the goal is met, " +
"the stuck gate fires, or a budget (steps/seconds) is exhausted. Returns the final page in a chosen format " +
"(text, markdown, html, or an aria snapshot), the full step trace with confidences, console/page/network " +
"errors captured along the way, token usage with estimated cost, and a final screenshot.",
inputSchema: {
task: z.string().min(1).describe("What the agent should accomplish, in natural language."),
start_url: z
.string()
.url()
.refine((v) => /^https?:\/\//.test(v), "start_url must be an http(s) URL")
.describe("Where to start."),
max_steps: z.number().int().min(1).max(100).optional().describe("Hard step cap. Default 24."),
max_seconds: z.number().min(10).max(600).optional().describe("Wall-clock cap in seconds. Default 180."),
allow_typing: z
.boolean()
.optional()
.describe("Whether the agent may type into fields (uses the configured small model, or a keyword fallback). Default true."),
format: z
.enum(["text", "markdown", "html", "aria"])
.optional()
.describe(
"Final page payload format: text (default, 8k chars), markdown (16k, via turndown), " +
"html (1MB, for app-side parsing), aria (16k, Playwright aria snapshot YAML).",
),
max_chars: z.number().int().min(100).optional().describe("Override the format's default character cap."),
screenshot: z.enum(["final", "none"]).optional().describe("Final viewport JPEG. Default 'final'."),
},
},
async ({ task, start_url, ...rest }, extra) => {
const result = await navigate(
{
task,
startUrl: start_url,
maxSteps: rest.max_steps,
maxSeconds: rest.max_seconds,
allowTyping: rest.allow_typing,
format: rest.format,
maxChars: rest.max_chars,
screenshot: rest.screenshot,
},
extra.signal,
);
const { screenshot_base64_jpeg, ...json } = result as Record<string, unknown>;
const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [
{ type: "text", text: JSON.stringify(json, null, 2) },
];
if (typeof screenshot_base64_jpeg === "string") {
content.push({ type: "image", data: screenshot_base64_jpeg, mimeType: "image/jpeg" });
}
return { content, isError: json.status === "error" };
},
);
await server.connect(new StdioServerTransport());
console.error(`[jev-browser] ready — Jev model ${process.env.JEV_BROWSER_MODEL ?? "jev-latest"}`);
// Pure helpers — no browser, no API, fully unit-testable.
/** Max elements offered to Jev per step. TypeSafe Choice supports up to 255 options. */
export const MAX_ELEMENTS = 240;
/** Raw interactive candidate as extracted from the page (already stamped with an attr). */
export interface RawElement {
attr: string; // data-jev-id attribute value stamped in the page
tag: string;
role: string;
text: string;
href: string;
typeAttr: string;
clickable: boolean;
typeable: boolean;
selectable?: boolean; // native <select>
options?: string[]; // option labels for selects
}
/** Pruned action-space element. */
export interface PageElement {
id: string; // e1, e2, ...
attr: string;
kind: "click" | "type" | "select";
description: string;
options?: string[]; // for kind === "select": the native option labels
}
const JUNK_NAMES = new Set([
"jump up", "jump up to", "jump up to:", "jump to content", "edit", "permalink",
"permanent link", "cite this page", "donate", "create account", "log in", "talk",
"contributions", "view history", "read", "source", "hide", "show", "skip to content",
]);
export function isNoiseName(name: string): boolean {
const lowered = name.trim().toLowerCase();
if (lowered.length === 0 || lowered.length > 80) return true;
if (JUNK_NAMES.has(lowered)) return true;
if (/^[\d\s.,:;()[\]-]+$/.test(lowered)) return true; // citation numbers, lone brackets
return false;
}
export function isNoiseHref(href: string): boolean {
if (!href) return false; // buttons and inputs legitimately have no href
if (href.startsWith("#")) return true;
if (href.startsWith("javascript:")) return true;
if (href.startsWith("mailto:") || href.startsWith("tel:")) return true;
if (href.includes("action=edit")) return true;
return false;
}
/** Filter, dedupe by destination, cap, and describe the action space for one step. */
export function buildActionSpace(raw: RawElement[]): { elements: PageElement[]; truncated: boolean } {
const seenHrefs = new Set<string>();
const elements: PageElement[] = [];
for (const el of raw) {
if (elements.length >= MAX_ELEMENTS) break;
if (isNoiseName(el.text)) continue;
if (isNoiseHref(el.href)) continue;
// Never offer to type into password or file inputs.
if (el.typeable && (el.typeAttr === "password" || el.typeAttr === "file")) continue;
if (el.href) {
const key = el.href.split("#")[0];
if (seenHrefs.has(key)) continue;
seenHrefs.add(key);
}
if (!el.clickable && !el.typeable && !el.selectable) continue;
const id = `e${elements.length + 1}`;
const label = el.text.slice(0, 60);
const kind: "click" | "type" | "select" = el.typeable ? "type" : el.selectable ? "select" : "click";
const hrefTail = el.href ? ` -> ${el.href.replace(/^https?:\/\//, "").slice(0, 70)}` : "";
elements.push({
id,
attr: el.attr,
kind,
description:
kind === "type"
? `${el.tag} "${label}" (type into this field)`
: kind === "select"
? `${el.tag} "${label}" (dropdown; a follow-up picks the option)`
: `${el.tag} "${label}"${hrefTail}`,
options: kind === "select" ? (el.options ?? []) : undefined,
});
}
return { elements, truncated: elements.length >= MAX_ELEMENTS };
}
/** Jev Choice criteria for one step: element actions plus loop controls. */
export function buildCriteria(elements: PageElement[]): Record<string, string> {
const criteria: Record<string, string> = {};
for (const el of elements) {
criteria[`${el.kind}_${el.id}`] = el.description;
}
criteria["scroll_down"] = "Scroll down one screen to reveal more of the page";
criteria["scroll_up"] = "Scroll up one screen";
criteria["back"] = "Go back to the previous page; this branch is wrong";
criteria["done"] = "The task is already complete; stop here";
return criteria;
}
export function selectorFor(el: PageElement): string {
return `[data-jev-id="${el.attr}"]`;
}
/** Next-best action from a Choice distribution, excluding known-bad options. */
export function pickAlternate(probabilities: Record<string, number> | undefined, exclude: Set<string>): string | null {
const ranked = Object.entries(probabilities ?? {}).sort((a, b) => b[1] - a[1]);
for (const [option, p] of ranked) {
if (option === "back") continue;
if (exclude.has(option)) continue;
if (p <= 0) continue;
return option;
}
return null;
}
const STOP_WORDS = new Set([
"search", "wikipedia", "the", "a", "an", "article", "about", "for", "find", "stop", "when",
"you", "are", "on", "it", "and", "to", "of", "called", "page", "site", "website", "navigate",
"go", "open", "that", "this",
]);
/** Deterministic typing fallback when no small-LLM key is available. */
export function heuristicQuery(task: string): string {
const words = task
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, " ")
.split(/\s+/)
.filter((w) => w && !STOP_WORDS.has(w));
return words.slice(0, 6).join(" ");
}
/** jev-1.12 published pricing: input $0.042 per M tokens, output free. */
export const PRICE_PER_MTOK_IN = 0.042;
// The navigation loop. Code owns control flow; Jev owns the judgments.
// Hardening pass applied per external review: stop gates run BEFORE action
// execution, the deadline is a real AbortSignal threaded through Jev, the
// typing generator, and every Playwright timeout, usage is per-run, and the
// final payload/screenshot extraction is best-effort.
import { chromium, type Browser, type Page } from "playwright";
import { generateText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import TurndownService from "turndown";
import * as gfm from "turndown-plugin-gfm";
import {
buildActionSpace,
buildCriteria,
heuristicQuery,
pickAlternate,
PRICE_PER_MTOK_IN,
RawElement,
selectorFor,
} from "./lib.js";
import { selectOptionQuestion, stepQuestions } from "./questions.js";
const MODEL = process.env.JEV_BROWSER_MODEL ?? "jev-latest";
const MAX_CONSOLE_EVENTS = 200;
const STATE_EXCERPT_CHARS = 1_500;
export interface NavigateOptions {
task: string;
startUrl: string;
maxSteps?: number;
maxSeconds?: number;
allowTyping?: boolean;
format?: "text" | "markdown" | "html" | "aria";
maxChars?: number;
screenshot?: "final" | "none";
recordDir?: string;
}
export interface StepRecord {
step: number;
t_ms?: number; // milliseconds after run start when this step began
proposed_action: string;
executed_action: string | null; // null when a watcher stopped the loop before execution
detail: string;
recovery_reason?: string;
action_error?: string;
outcome: string;
confidence: number | null;
top_probability: number | null;
goal_done: number;
stuck: number;
}
export interface ConsoleEvent {
step: number;
type: "console_error" | "console_warning" | "page_error" | "request_failed";
text: string;
page: string;
}
export interface JevUsage {
jev_calls: number;
input_tokens: number;
output_tokens: number;
est_cost_usd: number;
}
const DEFAULT_CAPS: Record<string, number> = {
text: 8_000,
markdown: 16_000,
html: 1_000_000,
aria: 16_000,
};
const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
turndown.use(gfm.gfm);
import { askJev as askProvider, type JevProvider } from "./provider.js";
interface RunBudget {
usage: JevUsage;
signal: AbortSignal;
deadlineAt: number; // performance.now() milliseconds
}
let lastProvider: JevProvider | null = null;
let lastModel: string | null = null;
async function askJev(budget: RunBudget, state: unknown, questions: Record<string, unknown>) {
const result = await askProvider(state, questions, MODEL, budget.signal);
lastProvider = result.provider;
lastModel = result.model;
budget.usage.jev_calls += 1;
budget.usage.input_tokens += result.usage.input_tokens;
budget.usage.output_tokens += result.usage.output_tokens;
budget.usage.est_cost_usd = (budget.usage.input_tokens / 1e6) * PRICE_PER_MTOK_IN;
return result.answers;
}
// ── Typing generator: provider-agnostic via the Vercel AI SDK ────────────────
function resolveGeneratorModel(): { model: Parameters<typeof generateText>[0]["model"]; label: string } | null {
const providerEnv = process.env.JEV_BROWSER_TYPE_PROVIDER;
const modelEnv = process.env.JEV_BROWSER_TYPE_MODEL;
// An explicit OpenAI-compatible endpoint wins: Ollama, LM Studio, vLLM, proxies.
const baseUrl = process.env.JEV_BROWSER_TYPE_BASE_URL;
if (baseUrl) {
const provider = createOpenAICompatible({
name: "custom",
baseURL: baseUrl,
apiKey: process.env.JEV_BROWSER_TYPE_API_KEY ?? "",
});
return { model: provider(modelEnv ?? "gpt-5.6-luna"), label: "compatible-endpoint" };
}
const candidates: Array<{ provider: string; test: RegExp; make: (m: string) => any; defaultModel: string }> = [
{
provider: "openai",
test: /^sk-/,
make: (m) => createOpenAI({ apiKey: process.env.OPENAI_API_KEY! })(m),
defaultModel: "gpt-5.6-luna",
},
{
provider: "openrouter",
test: /^sk-or-/,
make: (m) =>
createOpenAICompatible({
name: "openrouter",
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY!,
})(m),
defaultModel: "google/gemini-2.5-flash-lite",
},
{
provider: "anthropic",
test: /^sk-ant-/,
make: (m) => createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! })(m),
defaultModel: "claude-haiku-4.5",
},
{
provider: "google",
test: /^AIza/,
make: (m) => createGoogleGenerativeAI({ apiKey: (process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY)! })(m),
defaultModel: "gemini-2.5-flash",
},
];
// Explicit provider first, then auto-detection by key shape.
const ordered = providerEnv
? [...candidates.filter((c) => c.provider === providerEnv), ...candidates.filter((c) => c.provider !== providerEnv)]
: candidates;
for (const candidate of ordered) {
const key =
candidate.provider === "openai"
? (process.env.OPENAI_API_KEY ?? "")
: candidate.provider === "openrouter"
? (process.env.OPENROUTER_API_KEY ?? "")
: candidate.provider === "anthropic"
? (process.env.ANTHROPIC_API_KEY ?? "")
: (process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "");
if (key.length > 20 && candidate.test.test(key)) {
return { model: candidate.make(modelEnv ?? candidate.defaultModel), label: candidate.provider };
}
}
return null;
}
async function generateTextToType(
budget: RunBudget,
task: string,
elementDescription: string,
url: string,
): Promise<{ text: string; via: string }> {
const generator = resolveGeneratorModel();
if (!generator) return { text: heuristicQuery(task), via: "keyword-heuristic" };
try {
const { text } = await generateText({
model: generator.model,
prompt: `A browser agent is performing this task: "${task}". It must type into the ${elementDescription} on ${url}. Reply with ONLY the exact text to type (for a search box: a short search query; no quotes, no explanation).`,
maxOutputTokens: 48,
abortSignal: budget.signal,
});
const cleaned = text.trim().replace(/^["']|["']$/g, "");
if (cleaned.length === 0) throw new Error("empty generation");
return { text: cleaned, via: generator.label };
} catch (error) {
if (budget.signal.aborted) throw error; // deadline/cancellation propagates
// A bad model id or provider outage must not kill the task; degrade and say so.
return { text: heuristicQuery(task), via: "keyword-heuristic-after-generator-error" };
}
}
// ── Extraction: DOM-first (a11y trees under-report inputs) ───────────────────
async function extractAndStamp(page: Page, bounded: (cap: number) => number): Promise<RawElement[]> {
return page.evaluate(
() => {
// Clear stamps from previous steps first: elements that dropped out of
// the candidate list keep their old data-jev-id, which would make
// selectors match more than one element.
document.querySelectorAll("[data-jev-id]").forEach((el) => el.removeAttribute("data-jev-id"));
const SEL =
'a[href], button, input, textarea, select, [role="button"], [role="link"], [role="searchbox"], [role="textbox"]';
const out: any[] = [];
for (const el of document.querySelectorAll(SEL) as NodeListOf<HTMLElement>) {
// Cap accepted candidates AFTER filtering so hidden boilerplate at the
// top of the DOM cannot crowd out usable controls below it.
if (out.length >= 2000) break;
const rects = el.getClientRects();
if (!rects.length) continue;
const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") continue;
const tag = el.tagName.toLowerCase();
const roleAttr = el.getAttribute("role") || "";
const typeAttr = (el.getAttribute("type") || "").toLowerCase();
const label = (
el.getAttribute("aria-label") ||
el.getAttribute("placeholder") ||
el.getAttribute("title") ||
el.innerText ||
el.textContent ||
""
)
.replace(/\s+/g, " ")
.trim();
const href = tag === "a" ? el.getAttribute("href") || "" : "";
const clickable =
["a", "button"].includes(tag) ||
["button", "link"].includes(roleAttr) ||
["submit", "button", "checkbox", "radio"].includes(typeAttr);
const typeable =
tag === "textarea" ||
(tag === "input" && !["submit", "button", "checkbox", "radio", "file", "hidden", "range", "password"].includes(typeAttr)) ||
["searchbox", "textbox"].includes(roleAttr);
const selectable = tag === "select";
if (!clickable && !typeable && !selectable) continue;
const attr = `j${out.length + 1}`;
el.setAttribute("data-jev-id", attr);
const options =
tag === "select"
? Array.from((el as unknown as HTMLSelectElement).options)
.map((o) => (o.label || o.value || "").trim())
.filter(Boolean)
.slice(0, 200)
: undefined;
out.push({ attr, tag, role: roleAttr || tag, text: label.slice(0, 80), href, typeAttr, clickable, typeable, selectable, options });
}
return out;
});
}
interface Observables {
url: string;
title: string;
textLength: number;
scrollY: number;
excerpt: string;
}
async function pageObservables(page: Page, bounded: (cap: number) => number): Promise<Observables> {
const url = page.url();
const title = await page.title().catch(() => "");
const data = await page
.evaluate(() => ({
length: document.body?.innerText?.length ?? 0,
scrollY: window.scrollY,
excerpt: (document.body?.innerText ?? "").replace(/\s+/g, " ").slice(0, 1500),
}))
.catch(() => ({ length: 0, scrollY: 0, excerpt: "" }));
return { url, title, textLength: data.length, scrollY: data.scrollY, excerpt: data.excerpt };
}
async function settle(page: Page, bounded: (cap: number) => number) {
await page.waitForLoadState("domcontentloaded", { timeout: bounded(4_000) }).catch(() => {});
// DOM-stability settle: two consecutive identical fingerprints mean the page
// has stopped re-rendering, which is the signal we actually want; quiet
// network was only ever a proxy for it, and analytics pings keep heavy sites
// permanently noisy. Capped; a page that never settles still gets acted on.
const deadline = performance.now() + bounded(1_500);
let prev: string | null = null;
while (performance.now() < deadline) {
const fingerprint = await page
.evaluate(
() =>
`${document.body?.innerText?.length ?? 0}:${document.querySelectorAll("a,button,input,select,textarea").length}`,
)
.catch(() => null);
if (fingerprint !== null && fingerprint === prev) return; // DOM went quiet
prev = fingerprint;
await page.waitForTimeout(250);
}
await page.waitForTimeout(400); // never settled; act anyway
}
// ── The loop ─────────────────────────────────────────────────────────────────
export async function navigate(options: NavigateOptions, externalSignal?: AbortSignal) {
const {
task,
startUrl,
maxSteps = 24,
maxSeconds = 180,
allowTyping = true,
format = "text",
screenshot = "final",
} = options;
const maxChars = options.maxChars ?? DEFAULT_CAPS[format];
const started = performance.now();
const deadlineAt = started + maxSeconds * 1000;
// One abort source per run: the wall-clock deadline, optionally composed
// with caller cancellation (the MCP layer forwards its signal).
const controller = new AbortController();
const deadlineTimer = setTimeout(() => controller.abort(new Error("deadline-exceeded")), maxSeconds * 1000);
const onExternalAbort = () => controller.abort(new Error("cancelled-by-caller"));
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
if (externalSignal?.aborted) controller.abort(new Error("cancelled-by-caller"));
const budget: RunBudget = {
usage: { jev_calls: 0, input_tokens: 0, output_tokens: 0, est_cost_usd: 0 },
signal: controller.signal,
deadlineAt,
};
const remaining = () => Math.max(0, deadlineAt - performance.now());
const bounded = (cap: number) => Math.max(250, Math.min(cap, remaining() || 250));
const steps: StepRecord[] = [];
const consoleEvents: ConsoleEvent[] = [];
let consoleDropped = 0;
const currentStep = { n: 0 };
const extractionProblems: string[] = [];
let browser: Browser | null = null;
let status = "error";
const recordEvent = (event: Omit<ConsoleEvent, "step">) => {
if (consoleEvents.length >= MAX_CONSOLE_EVENTS) {
consoleDropped += 1;
return;
}
consoleEvents.push({ ...event, step: currentStep.n });
};
const attachPageObservers = (p: Page) => {
p.on("console", (msg) => {
const type = msg.type();
if (type !== "error" && type !== "warning") return;
recordEvent({ type: `console_${type}` as ConsoleEvent["type"], text: msg.text().slice(0, 300), page: p.url().slice(0, 120) });
});
p.on("pageerror", (err) => recordEvent({ type: "page_error", text: String(err).slice(0, 300), page: p.url().slice(0, 120) }));
p.on("requestfailed", (req) =>
recordEvent({
type: "request_failed",
text: `${req.method()} ${req.url().slice(0, 200)} ${req.failure()?.errorText ?? ""}`.slice(0, 300),
page: p.url().slice(0, 120),
}),
);
};
try {
browser = await chromium.launch({ headless: process.env.JEV_BROWSER_HEADED !== "1" });
const context = await browser.newContext({
viewport: { width: 1024, height: 640 },
...(options.recordDir ? { recordVideo: { dir: options.recordDir } } : {}),
});
// No Playwright default (30s) may ever outlive the run budget.
context.setDefaultTimeout(8_000);
let page = await context.newPage();
const videoPathPromise = options.recordDir ? page.video()?.path() : undefined;
attachPageObservers(page);
let pendingPage: Page | null = null;
context.on("page", (p) => {
attachPageObservers(p); // adopted tabs keep producing diagnostics
pendingPage = p;
});
await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: bounded(30_000) });
let lastExecuted: string | null = null;
let lastOutcome: string | null = null;
const history: Array<{ step: number; action: string; outcome: string }> = [];
for (let step = 1; step <= maxSteps; step++) {
currentStep.n = step;
if (remaining() <= 0) {
status = "timeout";
break;
}
const raw = await extractAndStamp(page, bounded);
const { elements, truncated } = buildActionSpace(raw);
const observables = await pageObservables(page, bounded);
// Empty action space is still judged normally: controls-only criteria
// (scroll/back/done) plus the page excerpt. goal_done can and should
// fire on terminal pages with no interactive elements.
const state = {
task,
current_page: { url: observables.url, title: observables.title },
page_text_excerpt: observables.excerpt,
interactive_elements: elements.map((e) => ({ id: e.id, description: e.description })),
element_list_truncated: truncated,
no_interactive_elements: elements.length === 0,
history,
};
const answers = await askJev(budget, state, stepQuestions(buildCriteria(elements)));
const actionAnswer = answers.action;
const proposed: string = actionAnswer.choice;
const probabilities: Record<string, number> = actionAnswer.probabilities ?? {};
const base = {
step,
t_ms: Math.round(performance.now() - started),
proposed_action: proposed,
confidence: actionAnswer.confidence ?? null,
top_probability: probabilities[proposed] ?? null,
goal_done: answers.goal_done.noul,
stuck: answers.stuck.noul,
};
// Stop gates run BEFORE execution: a watcher that fires on the current
// state must not be overridden by acting on that state.
if (proposed === "done") {
steps.push({ ...base, executed_action: null, detail: "done proposed; not executed", outcome: "agent declared done before acting" });
status = "done";
break;
}
if (answers.goal_done.noul > 0.85) {
steps.push({ ...base, executed_action: null, detail: "goal watcher fired; proposed action not executed", outcome: "goal watcher fired before acting" });
status = "goal_achieved";
break;
}
if (answers.stuck.noul > 0.85 && step > 2) {
steps.push({ ...base, executed_action: null, detail: "stuck watcher fired; proposed action not executed", outcome: "stuck watcher fired before acting" });
status = "stuck";
break;
}
// Repeat-no-op recovery: switch to the next-best option from the
// distribution. No low-confidence override by design: split probability
// across similar elements is usually several acceptable alternatives.
let chosen = proposed;
let recoveryReason: string | undefined;
if (lastExecuted === proposed && lastOutcome === "no visible change") {
const alternate = pickAlternate(probabilities, new Set([proposed]));
if (alternate) {
chosen = alternate;
recoveryReason = "repeated action had no effect; switched to next-best option";
}
}
const element = elements.find(
(e) => chosen === `click_${e.id}` || chosen === `type_${e.id}` || chosen === `select_${e.id}`,
);
let detail = chosen;
let actionError: string | undefined;
try {
if (chosen === "back") {
const wentBack = await page.goBack({ waitUntil: "domcontentloaded", timeout: bounded(10_000) }).catch(() => null);
detail = wentBack ? "went back" : "no history to go back to";
} else if (chosen === "scroll_down" || chosen === "scroll_up") {
await page.evaluate(
(dir) => window.scrollBy(0, dir * window.innerHeight * 0.8),
chosen === "scroll_down" ? 1 : -1,
);
detail = chosen;
} else if (!element) {
actionError = `unknown action ${chosen}`;
} else if (chosen.startsWith("type_")) {
if (!allowTyping) {
actionError = "typing disabled by caller";
} else {
const generated = await generateTextToType(budget, task, element.description, page.url());
await page.fill(selectorFor(element), generated.text, { timeout: bounded(4_000) });
await page.press(selectorFor(element), "Enter", { timeout: bounded(4_000) });
detail = `typed "${generated.text}" via ${generated.via}`;
}
} else if (chosen.startsWith("select_")) {
const opts = element.options ?? [];
if (opts.length === 0) {
actionError = "select had no options";
} else {
const optionAnswer = await askJev(
budget,
{ task, page: { url: observables.url, title: observables.title }, dropdown: element.description, options: opts },
{ option: selectOptionQuestion(element.description, opts) },
);
const pickedIndex = Number((optionAnswer.option.choice as string).slice(1));
const label = opts[pickedIndex] ?? opts[0];
await page.selectOption(selectorFor(element), { label });
detail = `selected "${label}"`;
}
} else {
await page.click(selectorFor(element), { timeout: bounded(4_000) });
detail = element.description;
}
} catch (error) {
if (controller.signal.aborted) throw error; // deadline/cancellation propagates
actionError = (error as Error).message.slice(0, 160);
}
await settle(page, bounded);
if (pendingPage) {
page = pendingPage;
pendingPage = null;
await settle(page, bounded);
detail += " (followed new tab)";
}
const after = await pageObservables(page, bounded);
// Execution failures are attributed to the action, not to ambient page
// changes that happened to occur in the same window.
const outcome = actionError
? "action failed"
: after.url !== observables.url
? `navigated to ${after.url}`
: after.title !== observables.title
? `page changed: "${after.title}"`
: Math.abs(after.textLength - observables.textLength) > 50
? "page content changed"
: Math.abs(after.scrollY - observables.scrollY) > 40
? "scrolled"
: "no visible change";
lastExecuted = chosen;
lastOutcome = outcome;
history.push({ step, action: chosen, outcome });
steps.push({
...base,
executed_action: chosen,
detail,
recovery_reason: recoveryReason,
action_error: actionError,
outcome,
});
if (step === maxSteps) status = "max_steps";
}
const finalObservables = await pageObservables(page, bounded);
let payload: { truncated: boolean; true_length: number; content: string } | null = null;
let screenshotBase64: string | null = null;
try {
payload = await extractPayload(page, format, maxChars, bounded);
} catch (error) {
extractionProblems.push(`page payload: ${(error as Error).message.slice(0, 160)}`);
}
if (screenshot === "final") {
try {
const buffer = await page.screenshot({ type: "jpeg", quality: 70, timeout: bounded(10_000) });
screenshotBase64 = buffer.toString("base64");
} catch (error) {
extractionProblems.push(`screenshot: ${(error as Error).message.slice(0, 160)}`);
}
}
await browser.close().catch(() => {});
const videoPath = (await videoPathPromise?.catch(() => undefined)) ?? null;
return {
status,
video_path: videoPath,
final_url: finalObservables.url,
final_title: finalObservables.title,
format,
max_chars: maxChars,
page: payload,
extraction_problems: extractionProblems.length ? extractionProblems : undefined,
steps,
console_events: consoleEvents,
console_events_dropped: consoleDropped,
usage: { ...budget.usage },
elapsed_ms: Math.round(performance.now() - started),
model: lastModel ?? MODEL,
jev_provider: lastProvider,
screenshot_base64_jpeg: screenshotBase64,
};
} catch (runError) {
const aborted = controller.signal.aborted;
status = aborted && String((runError as Error).message).includes("deadline") ? "timeout" : "error";
return {
status,
error: aborted ? `aborted: ${(runError as Error).message}` : (runError as Error).message,
steps,
console_events: consoleEvents,
console_events_dropped: consoleDropped,
usage: { ...budget.usage },
elapsed_ms: Math.round(performance.now() - started),
model: lastModel ?? MODEL,
jev_provider: lastProvider,
};
} finally {
clearTimeout(deadlineTimer);
externalSignal?.removeEventListener("abort", onExternalAbort);
await browser?.close().catch(() => {});
}
}
async function extractPayload(
page: Page,
format: string,
maxChars: number,
bounded: (cap: number) => number,
): Promise<{ truncated: boolean; true_length: number; content: string }> {
let content = "";
if (format === "html") {
// Strip the extraction stamps so returned HTML matches the page the user
// would see, not the instrumented one.
content = await page.evaluate(
() => {
const clone = document.documentElement.cloneNode(true) as HTMLElement;
clone.querySelectorAll("[data-jev-id]").forEach((el) => el.removeAttribute("data-jev-id"));
return clone.outerHTML;
});
} else if (format === "aria") {
content = await page.locator("body").ariaSnapshot({ timeout: bounded(10_000) });
} else if (format === "markdown") {
const html = await page.evaluate(() => document.body?.innerHTML ?? "");
content = turndown.turndown(html);
} else {
content = await page.evaluate(() => document.body?.innerText ?? "");
}
return {
truncated: content.length > maxChars,
true_length: content.length,
content: content.slice(0, maxChars),
};
}
// Jev transport: TypeSafe direct (default), OpenRouter Decisions, or
// Cloudflare Workers AI. All speak the {state, questions} / answers contract;
// URL, auth, and model slugs differ. Proxies add hops, so direct TypeSafe
// remains the recommended default.
import { experimental_evaluate } from "ai";
import { TypeSafeClient } from "@typesafe-ai/sdk";
export type JevProvider = "typesafe" | "openrouter" | "cloudflare" | "vercel";
export interface AskResult {
answers: Record<string, any>;
usage: { input_tokens: number; output_tokens: number };
provider: JevProvider;
model: string;
}
const X_TITLE = "jev-browser";
const REFERER = "https://github.com/jkudish/jev-browser";
let typesafeClient: TypeSafeClient | null = null;
function resolve(env: NodeJS.ProcessEnv): JevProvider {
const explicit = (env.JEV_PROVIDER ?? "auto").toLowerCase();
const hasTypesafe = Boolean(env.TYPESAFE_API_KEY);
const hasOpenRouter = /^sk-or-/.test(env.OPENROUTER_API_KEY ?? "");
const cfToken = env.JEV_CLOUDFLARE_API_TOKEN || env.CLOUDFLARE_API_TOKEN;
const hasCloudflare = Boolean(cfToken && env.CLOUDFLARE_ACCOUNT_ID);
if (explicit === "typesafe") {
if (!hasTypesafe) throw new Error("JEV_PROVIDER=typesafe but TYPESAFE_API_KEY is not set.");
return "typesafe";
}
if (explicit === "openrouter") {
if (!hasOpenRouter) throw new Error("JEV_PROVIDER=openrouter but OPENROUTER_API_KEY is not set or not an sk-or- key.");
return "openrouter";
}
if (explicit === "vercel") {
if (!env.AI_GATEWAY_API_KEY) throw new Error("JEV_PROVIDER=vercel but AI_GATEWAY_API_KEY is not set.");
return "vercel";
}
if (explicit === "cloudflare") {
if (!hasCloudflare) throw new Error("JEV_PROVIDER=cloudflare but a Cloudflare API token (CLOUDFLARE_API_TOKEN or JEV_CLOUDFLARE_API_TOKEN) and CLOUDFLARE_ACCOUNT_ID are not both set.");
return "cloudflare";
}
if (hasTypesafe) return "typesafe";
if (hasOpenRouter) return "openrouter";
if (hasCloudflare) return "cloudflare";
if (env.AI_GATEWAY_API_KEY) return "vercel";
throw new Error(
"No TYPESAFE_API_KEY, OPENROUTER_API_KEY (sk-or-), or Cloudflare token + CLOUDFLARE_ACCOUNT_ID found. Set one, or JEV_PROVIDER to choose explicitly.",
);
}
export async function askJev(
state: unknown,
questions: Record<string, unknown>,
model: string,
signal?: AbortSignal,
): Promise<AskResult> {
const provider = resolve(process.env);
if (provider === "typesafe") {
typesafeClient ??= new TypeSafeClient(
process.env.TYPESAFE_BASE_URL ? { baseURL: process.env.TYPESAFE_BASE_URL } : undefined,
);
const response = await (
typesafeClient.systemOne as unknown as (
payload: { state: unknown; questions: Record<string, unknown>; model?: string },
options?: { signal?: AbortSignal },
) => Promise<any>
)({ state, questions, model }, { signal });
return {
answers: response.answers,
usage: { input_tokens: response.usage?.input_tokens ?? 0, output_tokens: response.usage?.output_tokens ?? 0 },
provider,
model,
};
}
if (provider === "openrouter") {
// OpenRouter has no redirecting "latest" slug; map it to the current
// release. Pin exact versions with the model env var when that matters.
const OPENROUTER_LATEST = "jev-1.13";
const effective = model === "jev-latest" ? OPENROUTER_LATEST : model;
const slug = effective.startsWith("typesafe/") ? effective : `typesafe/${effective}`;
const response = await fetch("https://openrouter.ai/api/alpha/decisions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
"HTTP-Referer": REFERER,
"X-Title": X_TITLE,
"X-OpenRouter-Title": X_TITLE,
},
body: JSON.stringify({ model: slug, state, questions }),
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`OpenRouter decisions API ${response.status}: ${body.slice(0, 200)}`);
}
const body = await response.json();
return {
answers: body.answers ?? {},
// The decisions endpoint does not document a usage block; tolerate absence.
usage: { input_tokens: body.usage?.input_tokens ?? 0, output_tokens: body.usage?.output_tokens ?? 0 },
provider,
model: slug,
};
}
if (provider === "vercel") {
// Vercel AI Gateway exposes Jev through the AI SDK's experimental evaluate
// API: "noul" questions become "boolean", answers return as probabilities,
// and Choice/Score confidence lives in providerMetadata.typesafe.
const vercelQuestions: Record<string, any> = {};
for (const [id, question] of Object.entries(questions)) {
const q = question as { type: string; instructions?: unknown; criteria?: unknown };
vercelQuestions[id] = {
type: q.type === "noul" ? "boolean" : q.type,
instructions: q.instructions,
criteria: q.criteria,
};
}
const result = await experimental_evaluate({
model: model.startsWith("typesafe-ai/") ? model : "typesafe-ai/jev",
state: state as any,
questions: vercelQuestions as any,
abortSignal: signal,
});
const confidence = ((result as any).providerMetadata?.typesafe?.confidence ?? {}) as Record<string, number>;
const adapted: Record<string, any> = {};
for (const [id, answer] of Object.entries(result.answers as Record<string, any>)) {
if (answer?.type === "boolean") {
adapted[id] = { type: "noul", noul: answer.probability };
} else if (answer?.type === "choice") {
adapted[id] = { type: "choice", choice: answer.choice, probabilities: answer.probabilities ?? {}, confidence: confidence[id] ?? null };
} else if (answer?.type === "score") {
adapted[id] = { type: "score", score: answer.score, probabilities: answer.probabilities ?? {}, confidence: confidence[id] ?? null };
} else {
adapted[id] = answer;
}
}
return {
answers: adapted,
usage: { input_tokens: result.usage?.inputTokens ?? 0, output_tokens: result.usage?.outputTokens ?? 0 },
provider,
model: "typesafe-ai/jev",
};
}
// Cloudflare Workers AI wraps the same contract in {model, input} and the
// v4 {result, success} envelope. Single alias; no version pinning.
const cfSlug = model.startsWith("typesafe/") ? model : `typesafe/${model === "jev-latest" ? "jev" : model}`;
const cfResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/ai/run`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.JEV_CLOUDFLARE_API_TOKEN || process.env.CLOUDFLARE_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: cfSlug, input: { state, questions } }),
signal,
},
);
const cfBody = await cfResponse.json().catch(() => ({}));
if (!cfResponse.ok || cfBody.success === false) {
throw new Error(`Cloudflare AI run ${cfResponse.status}: ${JSON.stringify(cfBody.errors ?? cfBody).slice(0, 200)}`);
}
// The v4 envelope double-nests: body.result.result holds the model output.
const cfOuter = cfBody.result;
if (cfOuter && typeof cfOuter.state === "string" && cfOuter.state !== "Completed") {
throw new Error(`Cloudflare AI run state ${cfOuter.state}: ${JSON.stringify(cfBody.errors ?? []).slice(0, 200)}`);
}
const cfPayload = cfOuter?.result ?? cfOuter ?? cfBody;
return {
answers: cfPayload.answers ?? {},
usage: { input_tokens: cfPayload.usage?.input_tokens ?? 0, output_tokens: cfPayload.usage?.output_tokens ?? 0 },
provider,
model: cfPayload.model ?? cfSlug,
};
}
// The Jev question catalog — every question in one place, with the pattern it
// follows. Changing a question here changes every consumer; link the relevant
// TypeSafe doc when you change a design.
import { choice, noul } from "@typesafe-ai/sdk";
/**
* The primary Jev call for each step: three independent judgments over the
* same state. A select action adds one second-stage call for its option (see
* selectOptionQuestion), so "one call per step" means one primary call.
* Patterns: fan-out (docs.typesafe.ai/patterns/fan-out.md) — the questions
* cannot see each other's answers, which is what makes goal_done an honest
* cross-check on the action Choice rather than a rationalization of it.
*/
export function stepQuestions(criteria: Record<string, string>) {
return {
action: choice("Which single action best advances the task on the current page?", criteria),
goal_done: noul("The task's goal has been achieved: the current page and history show the sought outcome", {
true: "The page being viewed is the sought destination or shows the sought information",
false: "The goal is not yet achieved",
}),
stuck: noul("The actions so far are not making progress toward the task (repeats, loops, or no change)", {
true: "Recent actions repeat or nothing changes; a different strategy is needed",
false: "Progress is visible or the first steps are still reasonable",
}),
};
}
/**
* Second stage for native <select> elements, asked only when the step Choice
* picked select_eN. Option labels are the defined set; Jev picks the value.
* Two-stage pattern: docs.typesafe.ai (Choice cardinality is bounded at 255).
*/
export function selectOptionQuestion(elementDescription: string, options: string[]) {
const criteria: Record<string, string> = {};
options.forEach((label, i) => {
criteria[`o${i}`] = label.slice(0, 80);
});
return choice(
`Which option should be selected in the dropdown "${elementDescription}", given the task and the page?`,
criteria,
);
}
// End-to-end: spawn the built server over stdio and run real navigation tasks.
// Skipped unless TYPESAFE_API_KEY is set. Requires Playwright Chromium.
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const serverPath = fileURLToPath(new URL("../dist/index.js", import.meta.url));
const hasKey = Boolean(process.env.TYPESAFE_API_KEY);
async function withClient(fn) {
const client = new Client({ name: "jev-browser-e2e", version: "0.1.0" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [serverPath],
env: {
TYPESAFE_API_KEY: process.env.TYPESAFE_API_KEY ?? "",
...(process.env.OPENROUTER_API_KEY ? { OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY } : {}),
...(process.env.JEV_BROWSER_TYPE_MODEL ? { JEV_BROWSER_TYPE_MODEL: process.env.JEV_BROWSER_TYPE_MODEL } : {}),
},
});
await client.connect(transport);
try {
return await fn(client);
} finally {
await client.close();
}
}
function payload(result) {
const block = result.content?.find((b) => b.type === "text");
assert.ok(block, "tool returned no text content");
return JSON.parse(block.text);
}
test("lists the tool", { skip: !hasKey }, async () => {
await withClient(async (client) => {
const { tools } = await client.listTools();
assert.deepEqual(tools.map((t) => t.name), ["jev_navigate"]);
});
});
test("click-navigation: Coffee -> Espresso", { skip: !hasKey }, async () => {
await withClient(async (client) => {
const result = await client.callTool(
{
name: "jev_navigate",
arguments: {
task: "Navigate from the Coffee article to the Wikipedia article about Espresso and stop when you are on it",
start_url: "https://en.wikipedia.org/wiki/Coffee",
max_steps: 8,
max_seconds: 120,
},
},
undefined,
{ timeout: 240_000 },
);
const body = payload(result);
assert.ok(["done", "goal_achieved"].includes(body.status), `status was ${body.status}: ${JSON.stringify(body.steps)}`);
assert.match(body.final_url, /\/wiki\/Espresso/);
assert.ok(body.usage.jev_calls >= 2);
assert.ok(Array.isArray(body.console_events));
// The screenshot travels as a separate MCP image block, not in the JSON.
assert.ok(result.content.some((b) => b.type === "image"), "expected a screenshot image block");
});
});
test("typed search: find the Ristretto article", { skip: !hasKey }, async () => {
await withClient(async (client) => {
const result = await client.callTool(
{
name: "jev_navigate",
arguments: {
task: "Search Wikipedia for the espresso-based drink called Ristretto and stop when you are on that article",
start_url: "https://en.wikipedia.org/wiki/Main_Page",
max_steps: 8,
max_seconds: 120,
},
},
undefined,
{ timeout: 240_000 },
);
const body = payload(result);
assert.ok(["done", "goal_achieved"].includes(body.status), `status was ${body.status}: ${JSON.stringify(body.steps)}`);
assert.match(body.final_url, /Ristretto/);
});
});
test("clean termination on a hard page (informational)", { skip: !hasKey }, async () => {
await withClient(async (client) => {
const result = await client.callTool(
{
name: "jev_navigate",
arguments: {
task: "Find the TypeSafe AI blog post that introduces Jev and stop on that page",
start_url: "https://duckduckgo.com/",
max_steps: 6,
max_seconds: 90,
},
},
undefined,
{ timeout: 180_000 },
);
const body = payload(result);
assert.ok(
["done", "goal_achieved", "stuck", "max_steps", "timeout"].includes(body.status),
`unexpected status ${body.status}`,
);
// DOM-first extraction should see DuckDuckGo's search input even though its
// accessibility tree does not expose one. DuckDuckGo intermittently serves
// a bot-challenge page (50x-tq.html); when it does, clean termination is
// the most this test can demand.
const typedOk = body.steps.some((s) => /typed "/.test(s.detail ?? "") && !s.action_error);
const challenged = /50x|anomaly|challenge/i.test(body.final_url ?? "") || /50x/.test(body.final_title ?? "");
assert.ok(typedOk || challenged, "expected successful typing or a DuckDuckGo challenge page");
});
});