agents
Version:
A home for your AI agents
1,093 lines (1,092 loc) • 48.9 kB
JavaScript
import { i as _classPrivateFieldInitSpec, n as _classPrivateFieldSet2, r as _assertClassBrand, t as _classPrivateFieldGet2 } from "./classPrivateFieldGet2-DZBYAB34.js";
import { t as _classPrivateMethodInitSpec } from "./classPrivateMethodInitSpec-qMjJ6sHQ.js";
import { Validator } from "@cfworker/json-schema";
//#region src/browser/cdp-connection.ts
const DEFAULT_TIMEOUT_MS = 1e4;
/** JSON-RPC's "method not found" code, which Chrome also uses for CDP. */
const CDP_METHOD_NOT_FOUND = -32601;
/** The browser answered a command with a JSON-RPC error. */
var CdpProtocolError = class extends Error {
constructor(method, code, message) {
super(`CDP error ${code ?? "unknown"}: ${message} for ${method}`);
this.name = "CdpProtocolError";
this.code = code;
this.method = method;
}
};
const MAX_DEBUG_ENTRIES = 400;
var _socket = /* @__PURE__ */ new WeakMap();
var _nextId = /* @__PURE__ */ new WeakMap();
var _pending = /* @__PURE__ */ new WeakMap();
var _debugLog = /* @__PURE__ */ new WeakMap();
var _defaultTimeoutMs = /* @__PURE__ */ new WeakMap();
var _dispose = /* @__PURE__ */ new WeakMap();
var _disposed = /* @__PURE__ */ new WeakMap();
var _onActivity = /* @__PURE__ */ new WeakMap();
var _CdpConnection_brand = /* @__PURE__ */ new WeakSet();
/**
* One Chrome DevTools Protocol connection over an open WebSocket. Manages
* command correlation, timeouts, per-tab CDP sessions, and a debug event
* ring buffer.
*
* Used host-side (not in the sandbox) — the sandbox calls into this
* via DynamicWorkerExecutor's ToolDispatcher RPC.
*/
var CdpConnection = class {
constructor(socket, optionsOrTimeoutMs, onClose, sessionId) {
_classPrivateMethodInitSpec(this, _CdpConnection_brand);
_classPrivateFieldInitSpec(this, _socket, void 0);
_classPrivateFieldInitSpec(this, _nextId, 1);
_classPrivateFieldInitSpec(this, _pending, /* @__PURE__ */ new Map());
_classPrivateFieldInitSpec(this, _debugLog, []);
_classPrivateFieldInitSpec(this, _defaultTimeoutMs, void 0);
_classPrivateFieldInitSpec(this, _dispose, void 0);
_classPrivateFieldInitSpec(this, _disposed, false);
_classPrivateFieldInitSpec(this, _onActivity, void 0);
const options = typeof optionsOrTimeoutMs === "object" ? optionsOrTimeoutMs : {
timeoutMs: optionsOrTimeoutMs,
onClose,
sessionId
};
_classPrivateFieldSet2(_socket, this, socket);
_classPrivateFieldSet2(_defaultTimeoutMs, this, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
_classPrivateFieldSet2(_dispose, this, options.onClose);
this.sessionId = options.sessionId;
_classPrivateFieldSet2(_onActivity, this, options.onActivity);
socket.addEventListener("message", (event) => _assertClassBrand(_CdpConnection_brand, this, _handleMessage).call(this, event));
socket.addEventListener("error", () => {
_assertClassBrand(_CdpConnection_brand, this, _rejectAll).call(this, /* @__PURE__ */ new Error("CDP socket error"));
_assertClassBrand(_CdpConnection_brand, this, _runDispose).call(this);
});
socket.addEventListener("close", () => {
_assertClassBrand(_CdpConnection_brand, this, _rejectAll).call(this, /* @__PURE__ */ new Error("CDP connection closed"));
_assertClassBrand(_CdpConnection_brand, this, _runDispose).call(this);
});
}
send(method, params, options = {}) {
var _this$nextId, _this$nextId2;
_classPrivateFieldGet2(_onActivity, this)?.call(this);
const id = (_classPrivateFieldSet2(_nextId, this, (_this$nextId = _classPrivateFieldGet2(_nextId, this), _this$nextId2 = _this$nextId++, _this$nextId)), _this$nextId2);
const timeoutMs = options.timeoutMs ?? _classPrivateFieldGet2(_defaultTimeoutMs, this);
const sessionId = typeof options.sessionId === "string" && options.sessionId.length > 0 ? options.sessionId : void 0;
const domain = typeof method === "string" ? method.split(".")[0] : "";
if (!sessionId && domain && !["Browser", "Target"].includes(domain)) _assertClassBrand(_CdpConnection_brand, this, _recordDebug).call(this, "warning", {
id,
method,
reason: "target-scoped method sent without sessionId"
});
const result = new Promise((resolve, reject) => {
const startedAt = performance.now();
const timeoutId = setTimeout(() => {
_classPrivateFieldGet2(_pending, this).delete(id);
reject(/* @__PURE__ */ new Error(`CDP command timed out after ${timeoutMs}ms: ${method}`));
}, timeoutMs);
_classPrivateFieldGet2(_pending, this).set(id, {
resolve,
reject,
timeoutId,
method,
sessionId,
startedAt
});
});
_assertClassBrand(_CdpConnection_brand, this, _recordDebug).call(this, "send", {
id,
method,
sessionId,
timeoutMs
});
try {
_classPrivateFieldGet2(_socket, this).send(JSON.stringify({
id,
method,
params,
sessionId
}));
} catch (error) {
const pending = _classPrivateFieldGet2(_pending, this).get(id);
if (pending) {
clearTimeout(pending.timeoutId);
_classPrivateFieldGet2(_pending, this).delete(id);
pending.reject(error instanceof Error ? error : new Error(String(error)));
}
}
return result;
}
async attachToTarget(targetId, options = {}) {
if (typeof targetId !== "string" || !targetId) throw new Error("attachToTarget requires a targetId — list open tabs with send('Target.getTargets') or create one with send('Target.createTarget', { url })");
const sessionId = (await this.send("Target.attachToTarget", {
targetId,
flatten: true
}, { timeoutMs: options.timeoutMs }))?.sessionId ?? "";
if (!sessionId) throw new Error(`Target.attachToTarget did not return a sessionId for target ${targetId}`);
_assertClassBrand(_CdpConnection_brand, this, _recordDebug).call(this, "attach", {
targetId,
sessionId
});
return sessionId;
}
getDebugLog(limit = 50) {
const normalized = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 50;
return _classPrivateFieldGet2(_debugLog, this).slice(-normalized);
}
clearDebugLog() {
_classPrivateFieldSet2(_debugLog, this, []);
}
disconnect() {
_assertClassBrand(_CdpConnection_brand, this, _rejectAll).call(this, /* @__PURE__ */ new Error("CDP session disconnected"));
try {
_classPrivateFieldGet2(_socket, this).close(1e3, "Done");
} catch {}
}
close() {
this.disconnect();
_assertClassBrand(_CdpConnection_brand, this, _runDispose).call(this);
}
};
/**
* Run the dispose callback exactly once, on the first terminal event —
* an explicit {@link close}, peer closure, or a socket error.
*/
function _runDispose() {
if (_classPrivateFieldGet2(_disposed, this)) return;
_classPrivateFieldSet2(_disposed, this, true);
_classPrivateFieldGet2(_dispose, this)?.call(this);
}
function _rejectAll(error) {
for (const [id, pending] of _classPrivateFieldGet2(_pending, this).entries()) {
clearTimeout(pending.timeoutId);
_classPrivateFieldGet2(_pending, this).delete(id);
pending.reject(error);
}
}
function _handleMessage(event) {
if (typeof event.data !== "string") return;
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
_assertClassBrand(_CdpConnection_brand, this, _recordDebug).call(this, "receive", {
id: payload.id,
method: payload.method,
sessionId: payload.sessionId,
hasError: !!payload.error
});
if (typeof payload.id !== "number") return;
const pending = _classPrivateFieldGet2(_pending, this).get(payload.id);
if (!pending) return;
clearTimeout(pending.timeoutId);
_classPrivateFieldGet2(_pending, this).delete(payload.id);
if (payload.error) {
const err = payload.error;
pending.reject(new CdpProtocolError(pending.method, typeof err.code === "number" ? err.code : void 0, err.message ?? "CDP error"));
return;
}
pending.resolve(payload.result);
}
function _recordDebug(type, data) {
_classPrivateFieldGet2(_debugLog, this).push({
at: (/* @__PURE__ */ new Date()).toISOString(),
type,
...data
});
if (_classPrivateFieldGet2(_debugLog, this).length > MAX_DEBUG_ENTRIES) _classPrivateFieldGet2(_debugLog, this).splice(0, _classPrivateFieldGet2(_debugLog, this).length - MAX_DEBUG_ENTRIES);
}
const LOCALHOST_HOSTS = /* @__PURE__ */ new Set([
"localhost",
"127.0.0.1",
"0.0.0.0",
"::1",
"[::1]"
]);
/**
* Connect to a browser via a CDP base URL (e.g. http://localhost:9222).
* Discovers the WebSocket debugger URL via /json/version,
* rewrites localhost URLs to the base URL host, and opens the WebSocket.
*
* Useful for local development with `chrome --remote-debugging-port=9222`
* or when connecting through a tunnel.
*/
async function connectUrl(baseUrl, options) {
const endpoint = new URL("/json/version", baseUrl).toString();
const response = await fetch(endpoint, { headers: options?.headers });
if (!response.ok) throw new Error(`Failed to discover CDP endpoint at ${endpoint}: ${response.status}`);
const payload = await response.json();
if (!payload.webSocketDebuggerUrl) throw new Error("CDP /json/version did not include webSocketDebuggerUrl");
let wsUrl = payload.webSocketDebuggerUrl;
const parsed = new URL(wsUrl);
if (LOCALHOST_HOSTS.has(parsed.hostname)) {
const base = new URL(baseUrl);
parsed.hostname = base.hostname;
parsed.port = base.port;
parsed.protocol = base.protocol;
} else parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:";
const fetchUrl = parsed.toString();
const wsResponse = await fetch(fetchUrl, { headers: {
...options?.headers,
Upgrade: "websocket"
} });
const ws = wsResponse.webSocket;
if (!ws) throw new Error(`Failed to establish CDP WebSocket at ${fetchUrl} (status ${wsResponse.status})`);
ws.accept();
return new CdpConnection(ws, { timeoutMs: options?.timeoutMs });
}
/** @deprecated Renamed to {@link CdpConnection}. */
const CdpSession = CdpConnection;
//#endregion
//#region src/browser/validate.ts
/**
* Input validation for the `browser.*` functions. Internal — not an entry
* point. Used by `session.ts` and `browser-functions.ts`.
*/
const validators = /* @__PURE__ */ new WeakMap();
/**
* Check a method's input against its schema before it touches the browser,
* so the model gets a precise error. An omitted input counts as `{}`.
*/
function validate(name, schema, input) {
let validator = validators.get(schema);
if (!validator) {
validator = new Validator(schema, "7", false);
validators.set(schema, validator);
}
const value = input ?? {};
const result = validator.validate(value);
if (!result.valid) throw new Error(formatValidationError(name, result.errors));
return value;
}
function formatValidationError(name, errors) {
const specific = errors.filter((error) => error.keyword !== "properties");
return `Invalid arguments for ${name}${(specific.length > 0 ? specific : errors).map((error) => {
const location = error.instanceLocation.replace(/^#\/?/, "").replaceAll("~1", "/").replaceAll("~0", "~").replaceAll("/", ".");
return `${location ? ` at ${location}` : ""}: ${error.error}`;
}).join(";")}`;
}
//#endregion
//#region src/browser/browser-functions.ts
const SPEC_SCHEMA = {
type: "object",
properties: { domain: {
type: "string",
minLength: 1
} }
};
const BROWSER_FUNCTIONS = {
cdp: {
doc: "`browser.cdp({ method, params?, sessionId?, timeoutMs? })` sends a CDP command and returns its result without the JSON-RPC envelope. Page-scoped commands take `sessionId: \"active\"`: `await browser.cdp({ method: \"Page.navigate\", params: { url }, sessionId: \"active\" })` returns `{ frameId, errorText? }`. `timeoutMs` limits this one command. A failed command throws, and the error often says how to fix the call.",
call: (session, input) => session.send(input)
},
attachToTarget: {
doc: "`browser.attachToTarget({ targetId })` switches to an open tab, with `targetId` from `Target.getTargets`, and makes it active: `sessionId: \"active\"` points at it from then on. Returns `{ sessionId }` for that tab.",
call: (session, input) => session.attachToTarget(input)
},
spec: {
doc: "`browser.spec()` lists the protocol's domains as `{ domains: [{ name, description }] }`; `browser.spec({ domain: \"Page\" })` returns one domain's `{ commands, events, types }`, each command a `{ name, method, description, parameters, returns }` (`name` is `\"navigate\"`, `method` is `\"Page.navigate\"`). Big domains get cut short in your result, so filter in code: `(await browser.spec({ domain: \"Network\" })).commands.find((c) => c.name === \"enable\")`.",
call: async (session, input) => {
const { domain } = validate("browser.spec", SPEC_SCHEMA, input);
return specResult(await session.spec(), domain);
}
},
getDebugLog: {
doc: "`browser.getDebugLog({ limit? })` returns recent protocol traffic, newest last (50 entries unless `limit` says otherwise), each `{ at, type, ... }`. Use it when a command fails or times out.",
call: (session, input) => session.getDebugLog(input)
},
clearDebugLog: {
doc: "`browser.clearDebugLog()` forgets the traffic recorded so far, so the next `getDebugLog` shows only what follows.",
call: (session) => session.clearDebugLog()
}
};
/**
* Without a domain, every domain's name and first sentence, which is small;
* with one, that domain's commands, events and types. The whole protocol is
* about 650 KB, too much for one result.
*/
function specResult(spec, domain) {
const { domains } = spec;
if (domain === void 0) return {
domains: domains.map(({ name, description }) => ({
name,
...description ? { description: firstSentence(description) } : {}
})),
hint: "browser.spec({ domain: \"Page\" }) returns one domain's commands, events and types."
};
const found = domains.find((d) => d.name === domain);
if (found) return found;
const near = domains.find((d) => d.name.toLowerCase() === domain.toLowerCase());
throw new Error(`No CDP domain named '${domain}'.` + (near ? ` Did you mean '${near.name}'?` : " browser.spec() lists the domains."));
}
/** A CDP description's first sentence, for the list of domains. */
function firstSentence(text) {
return text.match(/^[\s\S]*?\.(?=\s|$)/)?.[0] ?? text;
}
/** The `host` field of a hosts function's input, or a fix-it error. */
function hostOf(name, input) {
const host = typeof input === "object" && input !== null ? input.host : void 0;
if (typeof host !== "string") throw new Error(`browser.${name} needs { host }: the bare hostname, like "preview.internal".`);
return host;
}
const HOSTS_FUNCTIONS = {
listHosts: {
doc: "`browser.listHosts()` returns each internal site and the target it points at.",
call: (hosts) => hosts.list()
},
assignHost: {
doc: "`browser.assignHost({ host, target })` points an assignable site at a target matching its schema and returns `{ url }`; `host` is the bare hostname, like \"preview.internal\". Navigate to the url next. It stays assigned across runs.",
assignable: true,
check: (input) => {
const host = hostOf("assignHost", input);
if (input.target === void 0) throw new Error(`browser.assignHost needs a target for ${host}; use browser.clearHost to unassign it`);
},
call: (hosts, input) => hosts.assign(hostOf("assignHost", input), input.target)
},
clearHost: {
doc: "`browser.clearHost({ host })` unassigns a site and returns `{ cleared }`. An unassigned site answers 502.",
assignable: true,
check: (input) => hostOf("clearHost", input),
call: async (hosts, input) => ({ cleared: await hosts.clear(hostOf("clearHost", input)) })
}
};
/** The hosts functions to offer: assign and clear only if any host can be. */
function hostsFunctions(infos) {
const assignable = infos.some((info) => info.assignable);
return Object.entries(HOSTS_FUNCTIONS).filter(([, definition]) => assignable || !definition.assignable);
}
/**
* The `browser` object's functions, bound to one run's session, plus the
* hosts functions when the browser has internal hostnames. Each hosts
* function checks its input, then waits for `admit`, then calls the hosts.
*/
function browserFunctions(session, sessionHosts) {
const functions = Object.fromEntries(Object.entries(BROWSER_FUNCTIONS).map(([name, definition]) => [name, (input) => definition.call(session, input)]));
if (sessionHosts) {
const { hosts, admit } = sessionHosts;
for (const [name, definition] of hostsFunctions(hosts.describe())) functions[name] = async (input) => {
definition.check?.(input);
await admit();
return definition.call(hosts, input);
};
}
return functions;
}
/** How one internal site reads in the docs. */
function describeHost(info) {
const description = info.description ? ` — ${info.description}` : "";
const target = info.assignable ? `. Assignable; target schema: ${JSON.stringify(info.targetSchema ?? {})}` : "";
return `- ${info.url}${description}${target}`;
}
/**
* The `browser` object's docs for the model: the functions offered for
* `hosts`, and the rules.
*/
function browserFunctionDocs(hosts) {
return {
functions: functionsMarkdown(hosts),
rules: [...BROWSER_RULES]
};
}
/**
* The functions in Markdown: one line per function, then the internal sites
* when the browser has any.
*/
function functionsMarkdown(hosts) {
const infos = hosts?.describe() ?? [];
const lines = [...Object.values(BROWSER_FUNCTIONS), ...hosts ? hostsFunctions(infos).map(([, definition]) => definition) : []].map((definition) => `- ${definition.doc}`);
if (!hosts) return lines.join("\n");
return [
...lines,
"",
"## Internal sites",
"",
"The browser can open these sites, which aren't on the public internet. Plain http only: no https:// and no WebSockets, so a dev server's hot reload won't connect; reload the page instead.",
"",
...infos.map(describeHost)
].join("\n");
}
/**
* How to use the browser, one rule per line. Each rule fixes a mistake
* models make with CDP, or states something about this browser they can't
* know; leave out what a model knows already. Rules about how the code runs
* (timeouts, screenshots) belong to whatever runs it.
*/
const BROWSER_RULES = [
"The browser persists between runs: tabs, cookies, and logins are still there next time. You never start or close it.",
"If the browser was replaced (idle timeout, crash), a notice says so and earlier tabs and logins are gone. Before an action that matters (submitting a form, a purchase), check you're on the page you expect.",
"Page-scoped commands (Page.*, Runtime.*, DOM.*, Input.*, Network.*, Emulation.*) need sessionId: \"active\", your tab, which is remembered between runs. Browser.* and Target.* commands take no sessionId.",
"Other open tabs (Target.getTargets) may belong to other conversations using this browser: don't close, navigate, or attach to a tab you didn't open unless the user asks.",
"Target.createTarget opens a tab and makes it active. To switch to another open tab, call browser.attachToTarget({ targetId }); the sessionId it returns keeps working in later runs, while one from a raw Target.attachToTarget only works in the current run.",
"Tabs a page opens itself (popups, target=_blank links) don't become active; a notice lists them.",
"Runtime.evaluate: pass returnByValue: true, or objects come back as a remote reference with no value, and awaitPromise: true for async expressions. The value is at result.value. A thrown error doesn't reject: check exceptionDetails.",
"Page.navigate returns before the page loads and doesn't throw when it fails: check errorText, then poll document.readyState with Runtime.evaluate until it is 'complete'.",
"Pick out what you need inside Runtime.evaluate and return only that. Page.captureScreenshot throws when its result is over 1 MB."
];
//#endregion
//#region src/browser/scope.ts
/**
* Browser scopes: who is driving a shared `Browser`. Sessions with the same
* scope share an active tab (and, when they lock, a lock); each scope
* keeps its own. Cookies and logins are shared by every scope.
*/
/**
* The scope used when a caller doesn't pick one: every caller that leaves
* `scope` out shares this one active tab. See `BrowserConnectOptions`.
*/
const DEFAULT_BROWSER_SCOPE = "shared";
/**
* The scope for one conversation: `conversation:<id>`. pi's `browserTool`
* gives each conversation this scope by default, so app code that opens
* sessions with the same scope (a Workspace's `codemode:browser`, say) lands
* in the same tab as the tool. pi's conversation ids are numbers: pass
* `String(conversationId)`.
*
* Throws on a missing or empty id rather than returning
* `"conversation:undefined"`, which would quietly put every conversation
* without an id in one tab.
*
* @experimental The API surface may change before stabilizing.
*/
function conversationScope(id) {
if (typeof id !== "string" || id === "") throw new Error(`conversationScope needs a conversation id, got ${String(id)}.`);
return `conversation:${id}`;
}
//#endregion
//#region src/browser/session.ts
/**
* `BrowserSession`: one run's connection to a persistent `Browser`, with the
* rules that make CDP usable from model-written code.
*
* The model never manages CDP sessions. `sessionId: "active"` addresses the
* tab the scope last worked in, `attachToTarget` returns a handle that keeps
* working in later runs, and common protocol mistakes come back as
* instructions. When the session closes it saves the scope's active tab and
* reports what happened to the browser: a restart, a tab closed by someone
* else, a tab shared with another scope, tabs the page opened.
*
* A session opened with `lock: true` holds its scope's lock from its first
* call (or `lock()`) until it closes; other locking sessions in the scope
* wait for it, in order.
*
* `browserTool` opens one per run.
*/
const RESTARTED_NOTICE = "The browser was restarted before this run (it expired or was closed): earlier tabs, logins, and page state are gone. Your code ran in a fresh browser — navigate again before relying on page state.";
const TAB_CLOSED_NOTICE = "The tab you were working in was closed (by another conversation using this browser, or a person). `sessionId: \"active\"` now opens a blank tab — navigate again before relying on page state.";
const TAB_SHARED_NOTICE = "Another conversation using this browser is also working in your tab, so its page may change between your runs. Open a tab of your own with Target.createTarget if you need one.";
/** What the report says, in sentences the model reads. */
function reportNotices(report) {
return [
report.restarted && RESTARTED_NOTICE,
report.tabClosed && !report.restarted && TAB_CLOSED_NOTICE,
report.tabShared && TAB_SHARED_NOTICE,
report.newTabs.length > 0 && `New tabs opened by the page: ${JSON.stringify(report.newTabs)}`
].filter((notice) => typeof notice === "string");
}
var _held = /* @__PURE__ */ new WeakMap();
var _waiting = /* @__PURE__ */ new WeakMap();
var _onIdle = /* @__PURE__ */ new WeakMap();
/**
* Who holds a scope's lock, and who waits for it, in order. A waiter that
* gives up is removed at once, so nobody behind it waits on it.
*/
var LockQueue = class {
/** `onIdle` runs when the lock is freed with nobody waiting. */
constructor(onIdle) {
_classPrivateFieldInitSpec(this, _held, false);
_classPrivateFieldInitSpec(this, _waiting, []);
_classPrivateFieldInitSpec(this, _onIdle, void 0);
_classPrivateFieldSet2(_onIdle, this, onIdle);
}
/** Resolve once this caller holds the lock. */
take(signal) {
if (signal.aborted) {
if (!_classPrivateFieldGet2(_held, this) && _classPrivateFieldGet2(_waiting, this).length === 0) _classPrivateFieldGet2(_onIdle, this).call(this);
return Promise.reject(signal.reason);
}
if (!_classPrivateFieldGet2(_held, this)) {
_classPrivateFieldSet2(_held, this, true);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const onAbort = () => {
const index = _classPrivateFieldGet2(_waiting, this).indexOf(waiter);
if (index !== -1) _classPrivateFieldGet2(_waiting, this).splice(index, 1);
reject(signal.reason);
};
const waiter = { grant: () => {
signal.removeEventListener("abort", onAbort);
resolve();
} };
_classPrivateFieldGet2(_waiting, this).push(waiter);
signal.addEventListener("abort", onAbort, { once: true });
});
}
/** Hand the lock to the next waiter, or free the scope. */
release() {
const next = _classPrivateFieldGet2(_waiting, this).shift();
if (next) {
next.grant();
return;
}
_classPrivateFieldSet2(_held, this, false);
_classPrivateFieldGet2(_onIdle, this).call(this);
}
};
/**
* The lock queues per browser and scope. Keyed by the source, so custom
* sources lock too, and dropped with it.
*/
const lockQueues = /* @__PURE__ */ new WeakMap();
function lockQueue(browser, scope) {
let queues = lockQueues.get(browser);
if (!queues) lockQueues.set(browser, queues = /* @__PURE__ */ new Map());
let queue = queues.get(scope);
if (!queue) {
const scopes = queues;
queue = new LockQueue(() => scopes.delete(scope));
queues.set(scope, queue);
}
return queue;
}
/** `sessionId` value that addresses the tab the agent is working in. */
const ACTIVE_PAGE_SESSION = "active";
/**
* `attachToTarget` returns `{ sessionId: "target:<targetId>" }`, a stable
* handle rather than a connection-scoped CDP session id, so it keeps working
* in later runs. `send` resolves it on the live socket.
*/
const ATTACH_HANDLE_PREFIX = "target:";
/** Chrome's JSON-RPC code for a CDP session id it doesn't know. */
const CDP_SESSION_NOT_FOUND = -32001;
/**
* Screenshots are kept whole in the chat message for the UI, and a stored
* message must fit SQLite's 2 MB row (see `ROW_MAX_BYTES` in chat/sanitize.ts),
* or the image is compacted away. Failing early lets the model retake it
* smaller. Other results are cut to a preview before they're stored.
*
* @internal The tools also refuse to keep a returned screenshot over it.
*/
const DEFAULT_MAX_RESULT_BYTES = 1e6;
/** `#liveParams`: nothing to detach. */
const DETACHED = Symbol("detached");
const MANAGED_BROWSER = "this browser is managed for you and persists between runs. To close a tab, send Target.closeTarget with its targetId.";
const HOST_ONLY = "it is for the host, not your code.";
const NO_TUNNELING = "send each command with browser.cdp instead, passing sessionId to reach a tab.";
/**
* Commands model code can't send, and why.
*
* - Ending the browser: the host owns its lifetime, so the model can't close
* it out from under the agent (and lose its logins).
* - Live View and handoff: a Live View link controls the whole browser, so it
* goes to a trusted person through `Browser.liveView()`, never to the model.
* - Tunnels: these carry raw protocol messages past this check.
*/
const REFUSED_COMMANDS = /* @__PURE__ */ new Map([
["Browser.close", MANAGED_BROWSER],
["Browser.crash", MANAGED_BROWSER],
["Browser.crashGpuProcess", MANAGED_BROWSER],
["Cloudflare.getLiveView", HOST_ONLY],
["Cloudflare.handoff", HOST_ONLY],
["Target.sendMessageToTarget", NO_TUNNELING],
["Target.exposeDevToolsProtocol", NO_TUNNELING]
]);
const SEND_SCHEMA = {
type: "object",
properties: {
method: {
type: "string",
minLength: 1
},
params: { type: "object" },
sessionId: { type: "string" },
timeoutMs: {
type: "number",
exclusiveMinimum: 0
}
},
required: ["method"]
};
const ATTACH_SCHEMA = {
type: "object",
properties: {
targetId: {
type: "string",
minLength: 1
},
timeoutMs: {
type: "number",
exclusiveMinimum: 0
}
},
required: ["targetId"]
};
const DEBUG_LOG_SCHEMA = {
type: "object",
properties: { limit: {
type: "number",
minimum: 1
} }
};
var _browser = /* @__PURE__ */ new WeakMap();
var _scope = /* @__PURE__ */ new WeakMap();
var _maxResultBytes = /* @__PURE__ */ new WeakMap();
var _locksScope = /* @__PURE__ */ new WeakMap();
var _state = /* @__PURE__ */ new WeakMap();
var _connecting = /* @__PURE__ */ new WeakMap();
var _closed = /* @__PURE__ */ new WeakMap();
var _functions = /* @__PURE__ */ new WeakMap();
var _acquiring = /* @__PURE__ */ new WeakMap();
var _heldLock = /* @__PURE__ */ new WeakMap();
var _closing = /* @__PURE__ */ new WeakMap();
var _waitedForLockMs = /* @__PURE__ */ new WeakMap();
var _BrowserSession_brand = /* @__PURE__ */ new WeakSet();
/**
* One run's connection to a persistent browser, for one scope.
*
* It connects on its first call and stays connected until {@link close}.
* Concurrent first calls share one connect. Every method takes one options
* object, validated before anything touches the browser, because the input
* usually comes from model-written code.
*
* Create one per run with `browser.session({ scope })`, and close it when
* the run ends. The browser itself stays open. Pass `lock: true` when
* runs in one scope may overlap; see {@link BrowserSessionOptions.lock}.
*
* @experimental The API surface may change before stabilizing.
*/
var BrowserSession = class {
constructor(browser, options = {}) {
_classPrivateMethodInitSpec(this, _BrowserSession_brand);
_classPrivateFieldInitSpec(this, _browser, void 0);
_classPrivateFieldInitSpec(this, _scope, void 0);
_classPrivateFieldInitSpec(this, _maxResultBytes, void 0);
_classPrivateFieldInitSpec(this, _locksScope, void 0);
_classPrivateFieldInitSpec(this, _state, void 0);
_classPrivateFieldInitSpec(this, _connecting, void 0);
_classPrivateFieldInitSpec(this, _closed, false);
_classPrivateFieldInitSpec(this, _functions, void 0);
_classPrivateFieldInitSpec(this, _acquiring, void 0);
_classPrivateFieldInitSpec(this, _heldLock, void 0);
_classPrivateFieldInitSpec(this, _closing, new AbortController());
_classPrivateFieldInitSpec(this, _waitedForLockMs, 0);
_classPrivateFieldSet2(_browser, this, browser);
_classPrivateFieldSet2(_scope, this, options.scope);
_classPrivateFieldSet2(_maxResultBytes, this, options.maxResultBytes ?? 1e6);
_classPrivateFieldSet2(_locksScope, this, options.lock ?? false);
}
/**
* The `browser.*` functions bound to this session, by name: the ones
* `Browser.functionDocs()` describes. Each takes one input object and
* validates it, so model-written code can call them directly. Like the
* session's methods, every one rejects once the session is closed, and
* waits for the scope's lock when the session locks.
*/
get functions() {
_classPrivateFieldGet2(_functions, this) ?? _classPrivateFieldSet2(_functions, this, Object.freeze(browserFunctions(this, _assertClassBrand(_BrowserSession_brand, this, _sessionHosts).call(this))));
return _classPrivateFieldGet2(_functions, this);
}
/**
* Take this scope's lock now rather than at the first call, and hold it
* until {@link close}. For sessions opened with `lock: true`; a
* caller with its own time limit calls it first, so the wait doesn't count
* against the limit, or passes `signal` to bound the wait.
*
* Resolves once the session holds the lock, after the locking sessions
* ahead of it close; later and concurrent calls resolve with the same
* lock. Rejects when the session closes while waiting. Each call's
* `signal` bounds only that call's wait: it rejects with the signal's
* reason, and the session leaves the queue once no call is still
* waiting (see {@link BrowserLockOptions.signal}); a later call or first
* call waits again.
*/
lock(options = {}) {
if (!_classPrivateFieldGet2(_locksScope, this)) return Promise.reject(/* @__PURE__ */ new Error("This browser session doesn't lock its scope. Open it with { lock: true }."));
if (_classPrivateFieldGet2(_closed, this)) return Promise.reject(/* @__PURE__ */ new Error("This browser session is closed."));
if (_classPrivateFieldGet2(_heldLock, this)) return Promise.resolve();
const { signal } = options;
if (signal?.aborted) return Promise.reject(signal.reason);
const acquisition = _classPrivateFieldGet2(_acquiring, this) ?? _classPrivateFieldSet2(_acquiring, this, _assertClassBrand(_BrowserSession_brand, this, _acquire).call(this));
if (acquisition.giveUp.signal.aborted) return acquisition.promise.then(() => void 0, () => this.lock(options));
if (!signal) {
acquisition.unbounded = true;
return acquisition.promise;
}
acquisition.bounded++;
return new Promise((resolve, reject) => {
const onAbort = () => {
acquisition.bounded--;
if (acquisition.bounded === 0 && !acquisition.unbounded) acquisition.giveUp.abort(signal.reason);
reject(signal.reason);
};
signal.addEventListener("abort", onAbort, { once: true });
acquisition.promise.then(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, (error) => {
signal.removeEventListener("abort", onAbort);
reject(error);
});
});
}
/**
* How long this session waited for its lock, in milliseconds: `0` until
* it has waited, and for sessions that don't lock.
*/
get waitedForLockMs() {
return _classPrivateFieldGet2(_waitedForLockMs, this);
}
/**
* Send a CDP command and resolve with its result, without the JSON-RPC
* envelope. `sessionId: "active"` runs it in the scope's tab.
*/
async send(input) {
const { method, params, sessionId, timeoutMs } = validate("browser.cdp", SEND_SCHEMA, input);
const refused = REFUSED_COMMANDS.get(method);
if (refused) throw new Error(`${method} is not available: ${refused}`);
const state = await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this);
const live = await _assertClassBrand(_BrowserSession_brand, this, _resolveSessionId).call(this, state, sessionId);
const liveParams = _assertClassBrand(_BrowserSession_brand, this, _liveParams).call(this, state, method, params);
if (liveParams === DETACHED) return {};
let result;
try {
result = await state.connected.cdp.send(method, liveParams, {
sessionId: live,
timeoutMs
});
} catch (error) {
throw await _assertClassBrand(_BrowserSession_brand, this, _teach).call(this, state, error, method, sessionId);
}
_assertClassBrand(_BrowserSession_brand, this, _observe).call(this, state, method, liveParams, result);
if (method === "Page.captureScreenshot") _assertClassBrand(_BrowserSession_brand, this, _assertFits).call(this, result);
return result;
}
/**
* Make an open tab the scope's active tab, and resolve with a `sessionId`
* handle for it that keeps working in later runs.
*/
async attachToTarget(input) {
const { targetId, timeoutMs } = validate("browser.attachToTarget", ATTACH_SCHEMA, input);
const state = await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this);
const target = targetId;
await _assertClassBrand(_BrowserSession_brand, this, _attach).call(this, state, target, timeoutMs);
_assertClassBrand(_BrowserSession_brand, this, _setActive).call(this, state, target);
return { sessionId: attachHandle(target) };
}
/** The Chrome DevTools Protocol description the browser serves. */
async spec() {
return (await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this)).connected.spec();
}
/** Recent CDP traffic on this session's connection, newest last. */
async getDebugLog(input) {
const { limit } = validate("browser.getDebugLog", DEBUG_LOG_SCHEMA, input);
return (await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this)).connected.cdp.getDebugLog(limit);
}
/** Forget the CDP traffic recorded so far. */
async clearDebugLog() {
(await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this)).connected.cdp.clearDebugLog();
return null;
}
/**
* End the session: save the scope's active tab, work out what happened to
* the browser, and close the connection. The browser stays open.
*
* Resolves with the report, or `undefined` when the session never
* connected. Never rejects: failures to read the browser's state are
* logged and leave the report incomplete. Later calls resolve with
* `undefined`, and other methods reject.
*
* A locking session releases the lock once the active tab is saved, or
* stops waiting for it if it was still waiting.
*/
async close() {
if (_classPrivateFieldGet2(_closed, this)) return void 0;
_classPrivateFieldSet2(_closed, this, true);
_classPrivateFieldGet2(_closing, this).abort(/* @__PURE__ */ new Error("This browser session is closed."));
try {
let state;
try {
state = _classPrivateFieldGet2(_state, this) ?? await _classPrivateFieldGet2(_connecting, this);
} catch {
return;
}
if (!state) return void 0;
_classPrivateFieldSet2(_state, this, void 0);
try {
const open = await _assertClassBrand(_BrowserSession_brand, this, _recordNewTabs).call(this, state);
const active = await _assertClassBrand(_BrowserSession_brand, this, _saveActiveTarget).call(this, state, open);
if (active && state.activeResolved) await _assertClassBrand(_BrowserSession_brand, this, _recordSharedTab).call(this, state, active);
} finally {
state.connected.cdp.disconnect();
}
return {
...state.report,
notices: reportNotices(state.report)
};
} finally {
await _classPrivateFieldGet2(_acquiring, this)?.promise.catch(() => void 0);
_classPrivateFieldGet2(_heldLock, this)?.release();
_classPrivateFieldSet2(_heldLock, this, void 0);
}
}
};
/**
* Connect once, after taking the lock if this session locks its scope.
* Concurrent first calls share one connect.
*/
function _connect() {
if (_classPrivateFieldGet2(_closed, this)) return Promise.reject(/* @__PURE__ */ new Error("This browser session is closed."));
if (_classPrivateFieldGet2(_state, this)) return Promise.resolve(_classPrivateFieldGet2(_state, this));
_classPrivateFieldGet2(_connecting, this) ?? _classPrivateFieldSet2(_connecting, this, _assertClassBrand(_BrowserSession_brand, this, _admitThenOpen).call(this).finally(() => {
_classPrivateFieldSet2(_connecting, this, void 0);
}));
return _classPrivateFieldGet2(_connecting, this);
}
/** The browser's internal hosts, behind this session's admission check. */
function _sessionHosts() {
const hosts = _classPrivateFieldGet2(_browser, this).internalHosts;
return hosts && {
hosts,
admit: () => _assertClassBrand(_BrowserSession_brand, this, _admit).call(this)
};
}
async function _admitThenOpen() {
await _assertClassBrand(_BrowserSession_brand, this, _admit).call(this);
return _assertClassBrand(_BrowserSession_brand, this, _open).call(this);
}
/**
* The check every function makes before it touches the browser: reject
* once the session is closed, and wait for the lock when the session
* locks. It doesn't connect.
*/
async function _admit() {
if (_classPrivateFieldGet2(_closed, this)) throw new Error("This browser session is closed.");
if (!_classPrivateFieldGet2(_locksScope, this)) return;
await this.lock();
if (_classPrivateFieldGet2(_closed, this)) throw new Error("This browser session is closed.");
}
/** Start waiting for the lock, given up when its calls do or on close. */
function _acquire() {
const giveUp = new AbortController();
const promise = _assertClassBrand(_BrowserSession_brand, this, _waitForLock).call(this, AbortSignal.any([giveUp.signal, _classPrivateFieldGet2(_closing, this).signal])).finally(() => {
if (_classPrivateFieldGet2(_acquiring, this)?.promise === promise) _classPrivateFieldSet2(_acquiring, this, void 0);
});
promise.catch(() => void 0);
return {
promise,
bounded: 0,
unbounded: false,
giveUp
};
}
async function _waitForLock(signal) {
signal.throwIfAborted();
const queue = lockQueue(_classPrivateFieldGet2(_browser, this), _classPrivateFieldGet2(_scope, this) ?? "shared");
const started = Date.now();
try {
await queue.take(signal);
_classPrivateFieldSet2(_heldLock, this, queue);
} finally {
_classPrivateFieldSet2(_waitedForLockMs, this, _classPrivateFieldGet2(_waitedForLockMs, this) + (Date.now() - started));
}
}
async function _open() {
const connected = await _classPrivateFieldGet2(_browser, this).connect(_classPrivateFieldGet2(_scope, this) === void 0 ? void 0 : { scope: _classPrivateFieldGet2(_scope, this) });
let initialPages;
try {
initialPages = await _assertClassBrand(_BrowserSession_brand, this, _pages).call(this, connected.cdp);
} catch (error) {
connected.cdp.disconnect();
throw error;
}
const state = {
connected,
attached: /* @__PURE__ */ new Map(),
workedIn: /* @__PURE__ */ new Set(),
initialPages: new Set(initialPages.map((page) => page.targetId)),
activeResolved: false,
closed: /* @__PURE__ */ new Set(),
report: {
restarted: connected.restarted,
newTabs: [],
tabClosed: false,
tabShared: false
}
};
if (_classPrivateFieldGet2(_closed, this)) return state;
_classPrivateFieldSet2(_state, this, state);
return state;
}
/**
* A screenshot over the limit is too big to keep in the chat message
* (see `DEFAULT_MAX_RESULT_BYTES`), so fail with how to take a smaller one.
*/
function _assertFits(result) {
const size = JSON.stringify(result ?? null).length;
if (size <= _classPrivateFieldGet2(_maxResultBytes, this)) return;
const megabytes = (size / 1e6).toFixed(1);
const limit = (_classPrivateFieldGet2(_maxResultBytes, this) / 1e6).toFixed(0);
throw new Error(`The screenshot result is ${megabytes} MB, over the ${limit} MB limit on a send result. Capture just the viewport (no captureBeyondViewport or clip larger than the window), or pass format: "jpeg" with quality: 60.`);
}
/**
* Add tabs the page opened during this session to the report. Returns the
* open tabs, or `undefined` when the browser couldn't be asked.
*/
async function _recordNewTabs(state) {
let pages;
try {
pages = await _assertClassBrand(_BrowserSession_brand, this, _pages).call(this, state.connected.cdp);
} catch (error) {
_assertClassBrand(_BrowserSession_brand, this, _warn).call(this, "list the open tabs", error);
return;
}
const opened = /* @__PURE__ */ new Set();
let grew = true;
while (grew) {
grew = false;
for (const page of pages) {
const opener = page.openerId;
if (!opener || opened.has(page.targetId) || state.initialPages.has(page.targetId)) continue;
if (state.workedIn.has(opener) || opened.has(opener)) {
opened.add(page.targetId);
grew = true;
}
}
}
state.report.newTabs = pages.filter((page) => opened.has(page.targetId)).map(({ targetId, url, title }) => ({
targetId,
url,
title
}));
return new Set(pages.map((page) => page.targetId));
}
/**
* Save the tab this session settled on, forgetting one that has since
* closed, and return it. Without the list of open tabs, save the choice
* unchecked: the next session checks the stored tab is still open before
* using it.
*/
async function _saveActiveTarget(state, open) {
const stored = state.connected.activeTargetId;
if (!state.activeResolved && stored && open && !open.has(stored) && !state.closed.has(stored)) state.report.tabClosed = true;
const candidate = state.activeResolved ? state.activeTargetId : stored;
const active = candidate && (!open || open.has(candidate)) ? candidate : void 0;
if (active === stored) return active;
try {
await state.connected.setActiveTarget(active);
} catch (error) {
_assertClassBrand(_BrowserSession_brand, this, _warn).call(this, "save the active tab", error);
}
return active;
}
/** Report when another scope is working in this session's tab too. */
async function _recordSharedTab(state, active) {
try {
if ((await state.connected.targetsInOtherScopes()).has(active)) state.report.tabShared = true;
} catch (error) {
_assertClassBrand(_BrowserSession_brand, this, _warn).call(this, "check whether the active tab is shared", error);
}
}
function _warn(action, error) {
console.warn(`[agents/browser] Failed to ${action} for browser "${_classPrivateFieldGet2(_browser, this).name}"`, error);
}
async function _pages(cdp) {
return ((await cdp.send("Target.getTargets"))?.targetInfos ?? []).filter((info) => info.type === "page");
}
async function _resolveSessionId(state, sessionId) {
if (sessionId === "active") return _assertClassBrand(_BrowserSession_brand, this, _attach).call(this, state, await _assertClassBrand(_BrowserSession_brand, this, _activeTarget).call(this, state));
if (isAttachHandle(sessionId)) return _assertClassBrand(_BrowserSession_brand, this, _attach).call(this, state, targetOfHandle(sessionId));
return sessionId;
}
/**
* Decide which tab `"active"` means, once per session: the scope's stored
* tab if it is still open, else a new blank tab. Never a tab the scope
* didn't open, even a blank one: it may be another scope's, or a popup.
*/
async function _activeTarget(state) {
if (state.activeTargetId) return state.activeTargetId;
const { connected } = state;
const pages = await _assertClassBrand(_BrowserSession_brand, this, _pages).call(this, connected.cdp);
const stored = connected.activeTargetId;
let targetId = pages.find((page) => page.targetId === stored)?.targetId;
if (!targetId && stored && !state.closed.has(stored)) state.report.tabClosed = true;
if (!targetId) targetId = (await connected.cdp.send("Target.createTarget", { url: "about:blank" })).targetId;
_assertClassBrand(_BrowserSession_brand, this, _setActive).call(this, state, targetId);
return targetId;
}
function _setActive(state, targetId) {
state.activeTargetId = targetId;
state.activeResolved = true;
}
async function _attach(state, targetId, timeoutMs) {
const existing = state.attached.get(targetId);
if (existing) return existing;
const live = await state.connected.cdp.attachToTarget(targetId, { timeoutMs });
state.attached.set(targetId, live);
state.workedIn.add(targetId);
return live;
}
/** Track tab changes the model makes through raw Target commands. */
function _observe(state, method, params, result) {
const targetId = typeof params?.targetId === "string" ? params.targetId : void 0;
if (method === "Target.createTarget") {
const created = result?.targetId;
if (typeof created === "string") _assertClassBrand(_BrowserSession_brand, this, _setActive).call(this, state, created);
} else if (method === "Target.attachToTarget" && targetId) {
_assertClassBrand(_BrowserSession_brand, this, _setActive).call(this, state, targetId);
state.workedIn.add(targetId);
} else if (method === "Target.closeTarget" && targetId) {
state.attached.delete(targetId);
state.closed.add(targetId);
if (state.activeTargetId === targetId) state.activeTargetId = void 0;
} else if (method === "Target.detachFromTarget") {
for (const [target, live] of state.attached) if (target === targetId || live === params?.sessionId) state.attached.delete(target);
}
}
/**
* Swap a handle in `Target.detachFromTarget`'s `sessionId` param for the
* live CDP session id. Returns {@link DETACHED} when the handle's tab isn't
* attached on this socket, so there is nothing to detach.
*/
function _liveParams(state, method, params) {
const handle = params?.sessionId;
if (method !== "Target.detachFromTarget" || typeof handle !== "string") return params;
if (!isAttachHandle(handle)) return params;
const live = state.attached.get(targetOfHandle(handle));
return live ? {
...params,
sessionId: live
} : DETACHED;
}
/**
* Turn the common protocol mistakes into instructions: an expired CDP
* session id, an event sent as a command, and a page-scoped command
* without a session.
*/
async function _teach(state, error, method, sessionId) {
if (!(error instanceof CdpProtocolError)) return error;
if (error.code === CDP_SESSION_NOT_FOUND && sessionId && sessionId !== "active" && !isAttachHandle(sessionId)) return /* @__PURE__ */ new Error(`${error.message}. CDP session ids don't carry over between runs. Use sessionId: "active" for the current tab, or the handle from browser.attachToTarget({ targetId }) for another tab.`);
if (error.code !== -32601) return error;
if (await _assertClassBrand(_BrowserSession_brand, this, _isEvent).call(this, state, method)) return /* @__PURE__ */ new Error(`${error.message}. '${method}' is a CDP *event*, not a command, so it cannot be sent. To wait for page state, poll instead (e.g. Runtime.evaluate of document.readyState until "complete").`);
if (!sessionId) return /* @__PURE__ */ new Error(`${error.message}. '${method}' is page-scoped: pass sessionId: "active" to run it in the current tab: browser.cdp({ method: "${method}", params, sessionId: "active" }).`);
return error;
}
async function _isEvent(state, method) {
try {
const spec = await state.connected.spec();
const domain = method.split(".")[0];
return spec.domains.some((d) => d.name === domain && d.events.some((e) => e.event === method));
} catch {
return false;
}
}
function attachHandle(targetId) {
return `${ATTACH_HANDLE_PREFIX}${targetId}`;
}
function isAttachHandle(sessionId) {
return sessionId?.startsWith(ATTACH_HANDLE_PREFIX) ?? false;
}
function targetOfHandle(handle) {
return handle.slice(7);
}
//#endregion
export { conversationScope as a, CdpConnection as c, connectUrl as d, DEFAULT_BROWSER_SCOPE as i, CdpProtocolError as l, BrowserSession as n, browserFunctionDocs as o, DEFAULT_MAX_RESULT_BYTES as r, CDP_METHOD_NOT_FOUND as s, ACTIVE_PAGE_SESSION as t, CdpSession as u };
//# sourceMappingURL=session-CRY0bGiI.js.map