diff options
| author | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-07-22 00:59:58 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-22 00:59:58 +0530 |
| commit | fe00d3361dece3d35f788d3eeb1c750eedbf6294 (patch) | |
| tree | a51b8ffe3cddb1aa8d457784f71cc1b2b0b61d56 /src | |
| parent | 7de0beaf2cf713afc50c573e941fa279ec6df9d9 (diff) | |
feat: add forms domain and durable window binding (v0.9.0) (#14)
- Add forms domain with a universal field setter that auto-detects element
type and fires framework-compatible input events (React native-setter trick)
- Add window-binding helper to capture and bind the real Chrome windowId as the
session's durable window identity on every new-window code path
- Wire supporting changes across CDP session/ownership, client, navigate,
isolated-tab, prompt, and state
- Add manual tests for forms and window binding
- Bump version 0.8.3 -> 0.9.0
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/cdp/ownership.ts | 20 | ||||
| -rw-r--r-- | src/cdp/session.ts | 55 | ||||
| -rw-r--r-- | src/cdp/window-binding.ts | 15 | ||||
| -rw-r--r-- | src/client.ts | 61 | ||||
| -rw-r--r-- | src/domains/forms.ts | 355 | ||||
| -rw-r--r-- | src/domains/isolated-tab.ts | 6 | ||||
| -rw-r--r-- | src/domains/navigate.ts | 35 | ||||
| -rw-r--r-- | src/index.ts | 17 | ||||
| -rw-r--r-- | src/prompt.ts | 208 | ||||
| -rw-r--r-- | src/state.ts | 1 |
10 files changed, 633 insertions, 140 deletions
diff --git a/src/cdp/ownership.ts b/src/cdp/ownership.ts index f221d6a..727b3d8 100644 --- a/src/cdp/ownership.ts +++ b/src/cdp/ownership.ts @@ -20,14 +20,24 @@ export type OwnershipRegistry = { replaceAll(ids: ReadonlyArray<string>): void; setHarnessWindow(targetId: string | undefined): void; harnessWindow(): string | undefined; + /** The real Chrome windowId of the dedicated harness window — the durable + * identity of "the window this session initialized", surviving seed-tab + * closure. Undefined until the window is created (or after teardown). */ + setHarnessWindowId(windowId: number | undefined): void; + harnessWindowId(): number | undefined; onChange(cb: () => void): void; }; export const createOwnershipRegistry = ( - initial?: { readonly ownedTargetIds?: ReadonlyArray<string>; readonly harnessWindowTargetId?: string }, + initial?: { + readonly ownedTargetIds?: ReadonlyArray<string>; + readonly harnessWindowTargetId?: string; + readonly harnessWindowId?: number; + }, ): OwnershipRegistry => { const owned = new Set<string>(initial?.ownedTargetIds ?? []); let harnessWindow: string | undefined = initial?.harnessWindowTargetId; + let harnessWindowId: number | undefined = initial?.harnessWindowId; let listener: (() => void) | null = null; const notify = (): void => { @@ -65,6 +75,14 @@ export const createOwnershipRegistry = ( harnessWindow() { return harnessWindow; }, + setHarnessWindowId(windowId) { + if (harnessWindowId === windowId) return; + harnessWindowId = windowId; + notify(); + }, + harnessWindowId() { + return harnessWindowId; + }, onChange(cb) { listener = cb; }, diff --git a/src/cdp/session.ts b/src/cdp/session.ts index e090858..03410ba 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -92,6 +92,17 @@ export const createCdpSession = ( const tab = targetId ? tabs.get(targetId) : undefined; if (tab) tab.pageInfoDirty = true; } + if (ev.method === "Target.targetCreated" && ownership) { + // Adopt tabs opened BY a tab we already own (window.open, target=_blank, + // popups). Chrome sets openerId to the opener target; if that opener is + // owned, the child belongs to this session's window and must be + // controllable + cleaned up. Targets the user opens themselves have no + // owned opener, so they are never adopted. + const info = (ev.params as { targetInfo?: { targetId?: string; type?: string; openerId?: string } } | undefined)?.targetInfo; + if (info?.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) { + ownership.add(info.targetId); + } + } if (ev.method === "Target.targetDestroyed" && ownership) { const params = ev.params as { targetId?: string } | undefined; if (params?.targetId) { @@ -179,6 +190,43 @@ export const createCdpSession = ( if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors); const hw = ownership.harnessWindow(); if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined); + + if (survivors.length === 0) { + // No owned tab survived — the harness window is gone (user closed it, + // or Chrome restarted and reassigned all ids). The persisted windowId + // is now meaningless and could even collide with one of the user's + // windows, so drop it. attachFirstPage will mint a fresh window below. + ownership.setHarnessWindowId(undefined); + } else { + // Self-heal ownership from the window itself. We anchor on a tab we + // KNOW we own (a survivor) and re-adopt only its live window-mates — + // popups/children from last session, tabs reopened in our window. + // Ownership is thus DERIVED from the real window, never from the bare + // persisted integer (which Chrome may reassign to a user window on + // restart — anchoring on a survivor makes that misfire impossible). + const anchorId = survivors[0]!; + const anchorWin = await transport.request("Browser.getWindowForTarget", { targetId: anchorId }, { sessionId: null }); + if (anchorWin.success) { + const anchorWindowId = (anchorWin.data as { windowId?: number }).windowId; + if (typeof anchorWindowId === "number") { + ownership.setHarnessWindowId(anchorWindowId); // refresh to the live id + const windows = await Promise.all( + allPages.map((p) => + p.targetId === anchorId + ? Promise.resolve(anchorWin) + : transport.request("Browser.getWindowForTarget", { targetId: p.targetId }, { sessionId: null }), + ), + ); + allPages.forEach((p, i) => { + const r = windows[i]; + if (r?.success && (r.data as { windowId?: number }).windowId === anchorWindowId) { + ownership.add(p.targetId); + } + }); + } + } + if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId); + } } // Prune per-tab state for pages that no longer exist const liveTargetIds = new Set(allPages.map((p) => p.targetId)); @@ -208,6 +256,13 @@ export const createCdpSession = ( if (ownership) { ownership.setHarnessWindow(c.targetId); ownership.add(c.targetId); + // Capture the real Chrome windowId — the durable identity of the + // window this session initialized (survives seed-tab closure). + const win = await transport.request("Browser.getWindowForTarget", { targetId: c.targetId }, { sessionId: null }); + if (win.success) { + const wid = (win.data as { windowId?: number }).windowId; + if (typeof wid === "number") ownership.setHarnessWindowId(wid); + } } } diff --git a/src/cdp/window-binding.ts b/src/cdp/window-binding.ts new file mode 100644 index 0000000..4ae1ac3 --- /dev/null +++ b/src/cdp/window-binding.ts @@ -0,0 +1,15 @@ +import type { BrowserClient } from "../client"; + +/** + * Capture the real Chrome windowId of a freshly-created harness window and bind + * it as the session's durable window identity. Best-effort: a failed CDP call + * leaves the previous binding untouched. Call this on every code path that + * spawns a NEW dedicated window (newWindow:true), so the windowId never points + * at a window that has since been replaced. + */ +export const bindHarnessWindowId = async (client: BrowserClient, targetId: string): Promise<void> => { + const r = await client.session().callBrowser("Browser.getWindowForTarget", { targetId }); + if (!r.success) return; + const wid = (r.data as { windowId?: number }).windowId; + if (typeof wid === "number") client.ownership().setHarnessWindowId(wid); +}; diff --git a/src/client.ts b/src/client.ts index 724a711..9a97369 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,10 +16,12 @@ export type BrowserClientOptions = { readonly initialOwnership?: { readonly ownedTargetIds?: ReadonlyArray<string>; readonly harnessWindowTargetId?: string; + readonly harnessWindowId?: number; }; readonly onOwnershipChange?: (snapshot: { readonly ownedTargetIds: ReadonlyArray<string>; readonly harnessWindowTargetId: string | undefined; + readonly harnessWindowId: number | undefined; }) => void; }; @@ -32,6 +34,9 @@ export type BrowserClient = { /** Detach from the current page target (removes the "Chrome is being controlled" banner) * while keeping the transport connection alive. Call on session shutdown. */ detach(): Promise<void>; + /** Close every tab this session owns and clear the window binding. Best-effort — + * used on session shutdown so no stale harness tabs survive. */ + closeOwnedTabs(): Promise<void>; evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>; pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>; takeDialog(): DialogInfo | null; @@ -82,13 +87,16 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => { export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => { const transport = opts.transport ?? createCdpTransport(); - const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; + const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {}; if (opts.initialOwnership?.ownedTargetIds !== undefined) { ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds; } if (opts.initialOwnership?.harnessWindowTargetId !== undefined) { ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId; } + if (opts.initialOwnership?.harnessWindowId !== undefined) { + ownershipInit.harnessWindowId = opts.initialOwnership.harnessWindowId; + } const ownership = createOwnershipRegistry(ownershipInit); if (opts.onOwnershipChange) { const cb = opts.onOwnershipChange; @@ -96,6 +104,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = cb({ ownedTargetIds: ownership.list(), harnessWindowTargetId: ownership.harnessWindow(), + harnessWindowId: ownership.harnessWindowId(), }); }); } @@ -256,6 +265,15 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = return ok(undefined); }; + // Ask Chrome for the real windowId hosting a target. Undefined on failure — + // callers treat the window binding as best-effort. + const getWindowId = async (targetId: string): Promise<number | undefined> => { + const r = await session.callBrowser("Browser.getWindowForTarget", { targetId }); + if (!r.success) return undefined; + const wid = (r.data as { windowId?: number }).windowId; + return typeof wid === "number" ? wid : undefined; + }; + const newTab = async (url?: string): Promise<Result<string, CdpError>> => { // Verify the recorded harness window still exists. Querying the live // target list also gives listTabs's reconciliation a chance to run. @@ -263,22 +281,35 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = if (!tabsResult.success) return tabsResult; const live = new Set(tabsResult.data.map((t) => t.targetId)); const hw = ownership.harnessWindow(); + // Pick an opener that keeps us inside the harness window. Prefer the seed + // tab; if the user closed it but other owned tabs survive, reuse one of + // those instead of spawning a second window (avoids window scatter). + const opener = hw && live.has(hw) + ? hw + : ownership.list().find((id) => live.has(id)); const params: Record<string, unknown> = { url: "about:blank" }; - if (hw && live.has(hw)) { - // Open as a child of the harness window's seed tab — Chrome places - // it in the same window, giving us visual grouping for free. - params["openerId"] = hw; + if (opener) { + // Open as a child of an owned tab — Chrome places it in the same window, + // giving us visual grouping for free and keeping the session single-window. + params["openerId"] = opener; } else { - // Either fresh session or the harness window was closed by the user. - // Spawn a new dedicated window. + // Fresh session, or the whole harness window was closed by the user. + // Spawn a new dedicated window and rebind to it. params["newWindow"] = true; } const created = await session.callBrowser("Target.createTarget", params); if (!created.success) return created; const c = created.data as { targetId: string }; - if (params["newWindow"]) ownership.setHarnessWindow(c.targetId); + if (params["newWindow"]) { + ownership.setHarnessWindow(c.targetId); + const wid = await getWindowId(c.targetId); + if (wid !== undefined) ownership.setHarnessWindowId(wid); + } else if (opener && opener !== hw) { + // Re-seeded onto a surviving owned tab; keep the seed pointer live. + ownership.setHarnessWindow(opener); + } ownership.add(c.targetId); const switched = await switchTab(c.targetId); @@ -297,6 +328,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = return ok(undefined); }; + const closeOwnedTabs = async (): Promise<void> => { + // Snapshot first — remove() mutates the list, and targetDestroyed events + // may also prune concurrently. Failures are swallowed: a tab the user + // already closed, or a dead transport, must not block shutdown. + for (const id of ownership.list()) { + await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {}); + ownership.remove(id); + } + ownership.setHarnessWindow(undefined); + ownership.setHarnessWindowId(undefined); + }; + const status = (): DaemonStatus => ({ alive: transport.state() === "open" && session.current() !== null, sessionId: session.current()?.sessionId ?? null, @@ -316,7 +359,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = return { namespace: opts.namespace, - ensureAlive, status, start, stop, detach, + ensureAlive, status, start, stop, detach, closeOwnedTabs, evaluateJs, pageInfo, takeDialog: () => session.takeDialog(), listTabs, switchTab, newTab, closeTab, diff --git a/src/domains/forms.ts b/src/domains/forms.ts new file mode 100644 index 0000000..1d83ba6 --- /dev/null +++ b/src/domains/forms.ts @@ -0,0 +1,355 @@ +import { Type } from "typebox"; +import type { BrowserClient } from "../client"; +import { type Result, err, ok } from "../util/result"; +import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; + +// ── Page-side functions ────────────────────────────────────────────────────── +// These run inside the page via Runtime.callFunctionOn with `this` bound to the +// resolved element. They are NOT string-interpolated with user data — the value +// is passed as a structured `arguments` entry, so safeJs is unnecessary here. + +/** + * Universal field setter. Auto-detects the element type and applies the right + * strategy, then reads the resulting value back so the caller can confirm. + * + * The <input>/<textarea> branch uses the "native setter" trick: React (and + * other frameworks) overload the element's `value` setter to track state, so a + * plain `el.value = x` updates the DOM but not the framework. Calling the + * prototype's original setter and then dispatching a bubbling `input` event is + * what makes React's onChange fire. + */ +const FILL_FN = ` +function(value) { + const el = this; + if (!el || el.nodeType !== 1) return { ok: false, reason: "ref does not point to an element" }; + if (el.disabled) return { ok: false, reason: "element is disabled" }; + const tag = (el.tagName || "").toLowerCase(); + const type = (el.type || "").toLowerCase(); + try { el.focus(); } catch (e) {} + const fire = function(t) { el.dispatchEvent(new Event(t, { bubbles: true })); }; + + if (tag === "select") { + const want = String(value); + let matched = null; + for (let i = 0; i < el.options.length; i++) { + const o = el.options[i]; + if (o.value === want || o.label === want || o.text === want) { matched = o; break; } + } + if (!matched) { + const opts = []; + for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text }); + return { ok: false, reason: "no matching option", kind: "select", options: opts }; + } + el.value = matched.value; + fire("input"); fire("change"); + return { ok: true, kind: "select", value: el.value, text: matched.text }; + } + + if (tag === "input" && (type === "checkbox" || type === "radio")) { + const want = value === true || value === "true" || value === "on" || value === 1; + if (el.checked !== want) { + try { el.click(); } catch (e) {} + if (el.checked !== want) { el.checked = want; fire("input"); fire("change"); } + } + return { ok: true, kind: type, checked: el.checked }; + } + + if (el.isContentEditable) { + el.textContent = String(value); + fire("input"); fire("change"); + return { ok: true, kind: "contenteditable", value: el.textContent }; + } + + if (tag === "input" || tag === "textarea") { + const proto = tag === "textarea" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; + const desc = Object.getOwnPropertyDescriptor(proto, "value"); + if (desc && desc.set) desc.set.call(el, String(value)); + else el.value = String(value); + fire("input"); fire("change"); + return { ok: true, kind: tag, value: el.value }; + } + + return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")" }; +}`; + +/** Specialized <select> setter — matches by value first, then by label/text. */ +const SELECT_FN = ` +function(value, label) { + const el = this; + if (!el || (el.tagName || "").toLowerCase() !== "select") return { ok: false, reason: "ref is not a <select> element" }; + if (el.disabled) return { ok: false, reason: "select is disabled" }; + let matched = null; + for (let i = 0; i < el.options.length; i++) { + const o = el.options[i]; + if (value != null && o.value === String(value)) { matched = o; break; } + if (label != null && (o.text === String(label) || o.label === String(label))) { matched = o; break; } + } + if (!matched) { + const opts = []; + for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text }); + return { ok: false, reason: "no matching option", options: opts }; + } + try { el.focus(); } catch (e) {} + el.value = matched.value; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + return { ok: true, value: el.value, text: matched.text }; +}`; + +/** Specialized checkbox/radio setter — only acts when the state differs. */ +const CHECK_FN = ` +function(checked) { + const el = this; + const tag = (el.tagName || "").toLowerCase(); + const type = (el.type || "").toLowerCase(); + if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" }; + if (el.disabled) return { ok: false, reason: "element is disabled" }; + const want = checked === true; + let changed = false; + if (el.checked !== want) { + try { el.click(); } catch (e) {} + if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); } + changed = true; + } + return { ok: true, checked: el.checked, changed: changed }; +}`; + +// ── Resolver ───────────────────────────────────────────────────────────────── + +/** Shape returned by the page-side functions above. */ +type PageResult = { + readonly ok: boolean; + readonly reason?: string; + readonly kind?: string; + readonly value?: unknown; + readonly text?: string; + readonly checked?: boolean; + readonly changed?: boolean; + readonly options?: ReadonlyArray<{ value: string; text: string }>; +}; + +/** + * Resolve a ref (CDP backendDOMNodeId) to a live JS handle and invoke a + * page-side function on it. DOM + Runtime are enabled on every session + * (see cdp/session.ts enableDomains), so no extra setup is required. + */ +const resolveAndCall = async ( + client: BrowserClient, + ref: number, + functionDeclaration: string, + args: ReadonlyArray<unknown>, +): Promise<Result<PageResult, ToolErr>> => { + const session = client.session(); + + const resolved = await session.call("DOM.resolveNode", { backendNodeId: ref }); + if (!resolved.success) { + return err({ + kind: "invalid_state", + message: `ref ${ref} could not be resolved — the page may have changed. Re-run browser_snapshot to get fresh refs.`, + details: { ref }, + }); + } + const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId; + if (objectId === undefined) { + return err({ + kind: "invalid_state", + message: `ref ${ref} resolved to no JS object — re-run browser_snapshot to get fresh refs.`, + details: { ref }, + }); + } + + try { + const called = await session.call("Runtime.callFunctionOn", { + objectId, + functionDeclaration, + arguments: args.map((v) => ({ value: v })), + returnByValue: true, + awaitPromise: true, + }); + if (!called.success) return err({ kind: "cdp_error", message: called.error.message, details: { ref } }); + const data = called.data as { result?: { value?: unknown }; exceptionDetails?: unknown }; + if (data.exceptionDetails !== undefined) { + return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(data.exceptionDetails)}`, details: { ref } }); + } + const value = data.result?.value; + if (typeof value !== "object" || value === null) { + return err({ kind: "internal", message: "page function returned no result object", details: { ref } }); + } + return ok(value as PageResult); + } finally { + // Best-effort: release the remote handle so it can be GC'd. Failure is + // harmless (the page may have navigated), so we ignore the result. + await session.call("Runtime.releaseObject", { objectId }); + } +}; + +/** Drop the `ok` flag before stashing a page result in tool `details` — the + * tool-result envelope reserves `ok` for its own success discriminant. */ +const detailsOf = (ref: number, res: PageResult, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => { + const { ok: _ok, ...rest } = res; + return { ref, ...rest, ...(extra ?? {}) }; +}; + +// ── Tools ──────────────────────────────────────────────────────────────────── + +const FieldValue = Type.Union([Type.String(), Type.Boolean()], { + description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.", +}); + +const FillArgs = Type.Object({ + ref: Type.Integer({ description: "Element ref from browser_snapshot ([ref=N])." }), + value: FieldValue, +}); + +export const fillTool = defineBrowserTool({ + name: "browser_fill", + label: "Browser Fill", + description: + "Fill a single form field by ref. Auto-detects input/textarea/select/checkbox/radio/contenteditable, clears then sets the value, fires input+change events (drives React/Vue controlled inputs correctly), and reads the value back to confirm. Get the ref from browser_snapshot's [ref=N].", + promptSnippet: "Fill a form field by ref (text, select, checkbox) — React-safe, self-confirming", + promptGuidelines: [ + "Get the ref from browser_snapshot's [ref=N] for the target field. No click-to-focus needed.", + "value is a string for text fields/selects/contenteditable, or a boolean for checkboxes/radios.", + "The result reports the value the field actually holds after filling — check it matches what you intended.", + "For autocomplete/typeahead that needs real keystrokes, click the field's @(x,y) then use browser_type instead.", + "If a ref no longer resolves, re-run browser_snapshot to get fresh refs.", + ], + parameters: FillArgs, + async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + const r = await resolveAndCall(client, args.ref, FILL_FN, [args.value]); + if (!r.success) return r; + const res = r.data; + if (!res.ok) { + return err({ kind: "invalid_state", message: `Could not fill ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) }); + } + const wanted = typeof args.value === "boolean" ? undefined : String(args.value); + const got = res.value !== undefined ? String(res.value) : undefined; + const mismatch = wanted !== undefined && got !== undefined && got !== wanted; + let text: string; + if (mismatch) { + text = `Filled ref ${args.ref}, but it now reads ${JSON.stringify(got)} (wanted ${JSON.stringify(wanted)})`; + } else if (got !== undefined) { + text = `Filled ref ${args.ref} = ${JSON.stringify(got)}`; + } else if (res.checked !== undefined) { + text = `Filled ref ${args.ref} (checked=${res.checked})`; + } else { + text = `Filled ref ${args.ref}`; + } + return ok({ text, details: detailsOf(args.ref, res, { mismatch }) }); + }, +}); + +const FillFormArgs = Type.Object({ + fields: Type.Array( + Type.Object({ + ref: Type.Integer({ description: "Element ref from browser_snapshot ([ref=N])." }), + value: FieldValue, + }), + { minItems: 1, description: "Fields to fill in one batch, each by ref." }, + ), +}); + +export const fillFormTool = defineBrowserTool({ + name: "browser_fill_form", + label: "Browser Fill Form", + description: + "Fill multiple form fields in one call — the efficient way to complete a form. Each field is { ref, value }; refs come from browser_snapshot's [ref=N]. Handles text inputs, textareas, selects, checkboxes, radios, and contenteditable, drives React/Vue controlled inputs correctly, and reports the resulting value of every field.", + promptSnippet: "Fill many form fields at once by ref — React-safe, self-confirming", + promptGuidelines: [ + "Preferred for forms: snapshot once, then fill all fields in a single browser_fill_form call.", + "Each field: { ref, value }. value is a string for text/select/contenteditable, boolean for checkbox/radio.", + "The result summarizes how many fields filled cleanly and flags any value mismatches or per-field errors.", + "Re-run browser_snapshot afterwards to verify the form state if needed.", + ], + parameters: FillFormArgs, + async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + const results: Array<Readonly<Record<string, unknown>>> = []; + let okCount = 0; + const issues: string[] = []; + for (const f of args.fields) { + const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]); + if (!r.success) { + results.push({ ref: f.ref, ok: false, error: r.error.message }); + issues.push(`ref ${f.ref} (${r.error.message})`); + continue; + } + const res = r.data; + const wanted = typeof f.value === "boolean" ? undefined : String(f.value); + const got = res.value !== undefined ? String(res.value) : undefined; + const mismatch = wanted !== undefined && got !== undefined && got !== wanted; + const fieldOk = res.ok === true && !mismatch; + if (fieldOk) okCount++; + else issues.push(`ref ${f.ref} (${res.ok === false ? (res.reason ?? "failed") : "value mismatch"})`); + results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) }); + } + const text = + `Filled ${okCount}/${args.fields.length} fields` + + (issues.length > 0 ? `; issues: ${issues.join(", ")}` : ""); + return ok({ text, details: { results } }); + }, +}); + +const SelectOptionArgs = Type.Object({ + ref: Type.Integer({ description: "Element ref of a <select> from browser_snapshot ([ref=N])." }), + value: Type.Optional(Type.String({ description: "Option value attribute to select." })), + label: Type.Optional(Type.String({ description: "Visible option text/label to select (used if value is omitted)." })), +}); + +export const selectOptionTool = defineBrowserTool({ + name: "browser_select_option", + label: "Browser Select Option", + description: + "Select an option in a native <select> dropdown by ref. Matches by option value, then by visible label/text, and fires change. Native selects cannot be driven by clicking coordinates — use this instead. Get the ref from browser_snapshot's [ref=N].", + promptSnippet: "Choose an option in a <select> dropdown by ref", + promptGuidelines: [ + "Provide value (the option's value attribute) or label (the visible text). value wins if both given.", + "On no match, the result lists the available options so you can retry with a valid one.", + "Use this for native <select> elements — clicking coordinates does not work for OS dropdowns.", + ], + parameters: SelectOptionArgs, + async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + if (args.value === undefined && args.label === undefined) { + return err({ kind: "invalid_state", message: "Provide value or label to select an option." }); + } + const r = await resolveAndCall(client, args.ref, SELECT_FN, [args.value ?? null, args.label ?? null]); + if (!r.success) return r; + const res = r.data; + if (!res.ok) { + return err({ kind: "invalid_state", message: `Could not select option on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) }); + } + return ok({ + text: `Selected ${JSON.stringify(res.text)} (value=${JSON.stringify(res.value)}) on ref ${args.ref}`, + details: detailsOf(args.ref, res), + }); + }, +}); + +const SetCheckedArgs = Type.Object({ + ref: Type.Integer({ description: "Element ref of a checkbox or radio from browser_snapshot ([ref=N])." }), + checked: Type.Boolean({ description: "Desired checked state." }), +}); + +export const setCheckedTool = defineBrowserTool({ + name: "browser_set_checked", + label: "Browser Set Checked", + description: + "Set a checkbox or radio to a desired checked state by ref. Only acts if the current state differs, fires change, and reports the final state. Get the ref from browser_snapshot's [ref=N].", + promptSnippet: "Set a checkbox/radio checked state by ref", + promptGuidelines: [ + "checked:true ticks the box, checked:false unticks it. Idempotent — no-op if already in the desired state.", + "For radios, set checked:true on the option you want; the group deselects the others.", + ], + parameters: SetCheckedArgs, + async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + const r = await resolveAndCall(client, args.ref, CHECK_FN, [args.checked]); + if (!r.success) return r; + const res = r.data; + if (!res.ok) { + return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) }); + } + return ok({ + text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed === false ? " (already)" : ""), + details: detailsOf(args.ref, res), + }); + }, +}); diff --git a/src/domains/isolated-tab.ts b/src/domains/isolated-tab.ts index c759cec..73ed158 100644 --- a/src/domains/isolated-tab.ts +++ b/src/domains/isolated-tab.ts @@ -13,6 +13,7 @@ import type { BrowserClient } from "../client"; import { type Result, err, ok } from "../util/result"; import { safeJs } from "../util/js-template"; +import { bindHarnessWindowId } from "../cdp/window-binding"; import type { ToolErr } from "../util/tool"; /** A tab attached by its own sessionId, ready to drive via callOnTarget. */ @@ -40,7 +41,10 @@ export const openIsolatedTab = async (client: BrowserClient): Promise<Result<Iso if (!created.success) return err(toToolErr(created.error.message)); const { targetId } = created.data as { targetId: string }; - if (createParams["newWindow"]) client.ownership().setHarnessWindow(targetId); + if (createParams["newWindow"]) { + client.ownership().setHarnessWindow(targetId); + await bindHarnessWindowId(client, targetId); + } client.ownership().add(targetId); const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true }); diff --git a/src/domains/navigate.ts b/src/domains/navigate.ts index 4477485..e402efc 100644 --- a/src/domains/navigate.ts +++ b/src/domains/navigate.ts @@ -4,6 +4,7 @@ import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; import { safeJs } from "../util/js-template"; +import { bindHarnessWindowId } from "../cdp/window-binding"; const NavigateArgs = Type.Object({ url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }), @@ -75,7 +76,7 @@ export const openUrlsTool = defineBrowserTool({ description: "Open multiple URLs in parallel new tabs. Returns per-URL outcomes.", promptSnippet: "Open multiple URLs in new tabs (parallel)", promptGuidelines: [ - "Use after web_search to open citations in parallel.", + "Use after browser_web_search to open citations in parallel.", "After opening, use browser_list_tabs / browser_switch_tab to navigate, then browser_snapshot to inspect each tab.", "Use browser_wait_for_load on a tab before extracting data from SPAs.", ], @@ -83,17 +84,31 @@ export const openUrlsTool = defineBrowserTool({ renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0), async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> { const total = args.urls.length; - // Phase 1: create tabs in parallel. Own them so they appear in - // browser_list_tabs scope:'owned' and group in the harness window. - const hw = client.ownership().harnessWindow(); - const created = await Promise.all(args.urls.map(async (url): Promise<TabResult> => { - const params: Record<string, unknown> = { url: "about:blank" }; - if (hw) params["openerId"] = hw; - else params["newWindow"] = true; - const r = await client.session().callBrowser("Target.createTarget", params); + // Phase 1: create tabs. All must land in ONE window. If the harness window + // already exists, open every url as its child in parallel. If it doesn't + // (fresh/closed), create the FIRST url as a new dedicated window (binding + // its windowId) and open the rest as children of it — a parallel + // newWindow:true per url would otherwise scatter tabs across N windows. + let seed = client.ownership().harnessWindow(); + let seedResult: TabResult | undefined; + if (!seed) { + const url0 = args.urls[0]; + if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" }); + const r0 = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true }); + if (!r0.success) return err({ kind: "cdp_error", message: r0.error.message }); + seed = (r0.data as { targetId: string }).targetId; + client.ownership().setHarnessWindow(seed); + await bindHarnessWindowId(client, seed); + client.ownership().add(seed); + seedResult = { url: url0, targetId: seed, ok: true }; + } + const seededWindow = seed; + const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => { + // Reuse the seed tab (created above) for the first url instead of duplicating it. + if (i === 0 && seedResult) return seedResult; + const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank", openerId: seededWindow }); if (!r.success) return { url, targetId: "", ok: false, error: r.error.message }; const c = r.data as { targetId: string }; - if (params["newWindow"]) client.ownership().setHarnessWindow(c.targetId); client.ownership().add(c.targetId); return { url, targetId: c.targetId, ok: true }; })); diff --git a/src/index.ts b/src/index.ts index d5f3435..7109ea0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,9 +109,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { if (!client) { const transport = createDaemonTransport(state.namespace); - const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; + const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {}; if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds; if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId; + if (state.harnessWindowId !== undefined) initialOwnership.harnessWindowId = state.harnessWindowId; client = createBrowserClient({ namespace: state.namespace, @@ -124,6 +125,9 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { ...(snap.harnessWindowTargetId !== undefined ? { harnessWindowTargetId: snap.harnessWindowTargetId } : {}), + ...(snap.harnessWindowId !== undefined + ? { harnessWindowId: snap.harnessWindowId } + : {}), }; // Ownership changes can fire from CDP events at any time, // including after session replacement when pi's ctx is stale. @@ -147,20 +151,27 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { }); pi.on("session_shutdown", async () => { - persistState(pi, state); if (client) { try { // Detach from the page target (removes the "Chrome is being controlled" // banner) but keep the transport alive for the next session. await client.detach(); + // Close every tab this session opened so no stale harness tabs linger + // in the user's browser between sessions. This also clears the persisted + // ownership + window binding via onOwnershipChange, so the next session + // starts with a fresh dedicated window rather than reattaching to leftovers. + await client.closeOwnedTabs(); } catch (e) { - console.warn("[pi-browser-harness] client.detach() failed during shutdown:", e); + console.warn("[pi-browser-harness] browser teardown failed during shutdown:", e); } // ponytail: keep the transport alive across sessions. // Do NOT call client.stop() or null out client — the daemon connection // persists and eliminates the per-session "Allow Remote Debugging" prompt. // Only Chrome restart or daemon death triggers a new prompt. } + // Persist last — closeOwnedTabs mutates `state` through onOwnershipChange, + // so this captures the cleared ownership set. + persistState(pi, state); await cleanupTempDirs(); }); diff --git a/src/prompt.ts b/src/prompt.ts index 7483730..c336c0c 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -2,150 +2,126 @@ * System prompt guidance for browser usage. * * Injects a compact browser control reference into the pi system prompt. - * Per-tool guidelines are injected via promptGuidelines on each tool registration. - * This prompt provides only the cross-cutting patterns and tool reference table. + * This prompt carries only cross-cutting knowledge — the mental model, tool + * routing, workflows, and gotchas. Per-tool specifics (schemas, detailed usage) + * are injected separately via promptGuidelines on each tool registration, so this + * file intentionally does NOT restate full tool descriptions. */ export function getBrowserSystemPrompt(): string { return ` ## Browser Control -You have full control of a real Chrome browser via \`browser_*\` tools. -The browser connects to the user's running Chrome (not a headless instance). - -**These tools coexist with all other standard pi tools** — read, bash, edit, -write, and any other built-in tools are still available. Use browser_* tools -to interact with pages visually, and other tools alongside them. - -### Browser-Specific Tools - -| Tool | Purpose | -|------|---------| -| browser_snapshot | Accessibility-tree snapshot. Assigns every interactive element a stable **ref** (\`[eN]\`) plus \`@(x,y)\`. Refs are the primary handle for interaction tools — they survive re-renders, coordinates don't. | -| browser_screenshot | Capture a PNG or JPEG screenshot of the current page (rendered inline in the TUI; the LLM reads the image via the saved file path) | -| browser_click | Click an element by **ref** (preferred — re-resolves position, survives re-renders) or viewport x/y. Appends a compact diff of page changes. | -| browser_type | Type text into the focused element (real keystrokes; requires a focused field) | -| browser_fill | Fill an input/textarea by **ref** (preferred) or selector — updates React/Vue controlled components, verifies the value, appends a page-changes diff | -| browser_select_option | Select an option in a native <select> by **ref** (preferred) or selector, choosing by value/label/index (fires change) | -| browser_focus | Focus an element by **ref** (preferred) or selector (deterministic; use before browser_type) | -| browser_press_key | Press a keyboard key (Enter, Tab, Escape, arrows, etc.) | -| browser_scroll | Scroll the page at coordinates | -| browser_navigate | Navigate to a URL (result details.outcome.kind is "in_place" or "new_tab_created") | -| browser_new_tab | Open a new tab, optionally navigate | -| browser_open_urls | Open multiple URLs in new tabs (parallel) | -| browser_go_back / browser_go_forward / browser_reload | History navigation | -| browser_page_info | Get page URL, title, viewport, scroll position, or dialog info | -| browser_list_tabs | List all open browser tabs | -| browser_current_tab | Get current tab info | -| browser_switch_tab | Switch to a different tab by targetId | -| browser_execute_js | Execute JavaScript and return the result | -| browser_http_get | Direct HTTP GET (outside browser, for APIs) | -| browser_console | Read JS errors / console output on the current tab (diagnostic — use when an action looks broken; pass sinceSeq from the previous nextCursor to see only new messages) | -| browser_wait | Wait N seconds | -| browser_wait_for | Wait until a selector/text appears (or a selector disappears) — use for SPA content before interacting | -| browser_wait_for_load | Wait for document.readyState === 'complete' (returns a typed timeout error if the page doesn't reach readyState=complete in N seconds, default 15) | -| browser_handle_dialog | Accept or dismiss a JS dialog | -| browser_run_script | Execute a temporary script file with daemon access (write script to disk, then run) | - -### Temporary Scripts - -When the built-in tools aren't enough for a multi-step workflow, write a temporary -script to disk and execute it with browser_run_script. The script runs in the -harness process with direct access to the browser daemon and Node.js APIs. +You drive a **real Chrome** (the user's running instance, not headless) through +\`browser_*\` tools. These coexist with every standard pi tool — read, bash, edit, +write — so mix them freely: \`browser_*\` for the page, everything else alongside. -\`\`\` -write("/tmp/scrape-pages.js", \` - const results = []; - for (const url of params.urls) { - await daemon.cdp("Page.navigate", { url }); - await new Promise(r => setTimeout(r, 2000)); - const data = await daemon.evaluateJS("document.title"); - results.push({ url, title: data }); - } - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; -\`) -browser_run_script("/tmp/scrape-pages.js", { urls: [...] }) -\`\`\` +### The core handle: refs -Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON, Buffer, setTimeout, clearTimeout. +Interaction is ref-first. Call \`browser_snapshot\` to get an accessibility tree +where every interactive element has a stable ref (\`[eN]\`) and \`@(x,y)\`. **Target +elements by ref, not by coordinates or guessed selectors** — refs are keyed to +element identity, so they survive the re-renders that React/Vue forms trigger on +every edit and save. Coordinates go stale; guessed selectors miss. -### Parallel Execution +- Prefer \`browser_fill\` for text — it fires input/change so controlled components + update, and returns the value for verification. Use \`browser_click\` + \`browser_type\` + only for keystroke-sensitive widgets (autocomplete, masked inputs). +- Mutating calls append a compact **"Page changes"** diff — read it to confirm the + change landed (form closed, new \`*[eN]\` appeared). +- \`"ref is stale"\` means the page changed: re-run \`browser_snapshot\` for fresh refs. -Observation tools (browser_screenshot, browser_page_info, browser_execute_js, browser_list_tabs, browser_http_get, etc.) can run in parallel with each other and with mutation tools. The harness automatically serializes mutation tools (click, type, scroll, navigate, switch_tab, etc.) so they never race on shared state. When operations are independent, emit them in the same turn for better performance. +### Choosing a tool -**Examples of safe parallel calls:** -\`\`\` -browser_screenshot() + browser_page_info() + browser_execute_js("document.title") -browser_http_get("https://api.example.com/data") + browser_click(x, y) -\`\`\` +| To… | Reach for | +|-----|-----------| +| See the page structure (for interaction) | \`browser_snapshot\` (refs) | +| See the page visually | \`browser_screenshot\` | +| Search the web | \`browser_web_search\` → ranked {title, url, snippet} | +| Read an article's clean text | \`browser_read_page\` (reader mode; url or owned tab) | +| Extract specific DOM values | \`browser_execute_js\` | +| Hit a JSON/API endpoint | \`browser_http_get\` (raw GET, outside the browser) | +| Click / fill / type / select / focus / press a key | \`browser_click\` · \`browser_fill\` · \`browser_type\` · \`browser_select_option\` · \`browser_focus\` · \`browser_press_key\` | +| Upload a file · drag · resize viewport | \`browser_upload_file\` · \`browser_drag_and_drop\` · \`browser_viewport_resize\` | +| Go to a URL / open many / manage tabs | \`browser_navigate\` · \`browser_new_tab\` · \`browser_open_urls\` · \`browser_list_tabs\` · \`browser_switch_tab\` · \`browser_go_back\`/\`go_forward\`/\`reload\` | +| Wait for content / load / a fixed delay | \`browser_wait_for\` (selector/text) · \`browser_wait_for_load\` (readyState) · \`browser_wait\` | +| Inspect page / handle a dialog | \`browser_page_info\` · \`browser_current_tab\` · \`browser_handle_dialog\` | +| Diagnose a broken action | \`browser_console\` (JS errors) · \`browser_get_network_log\` | +| Save output / config downloads | \`browser_print_to_pdf\` · \`browser_download\` | +| Anything the tools can't express | \`browser_run_script\` (see Extending) | -**Multi-agent note**: When multiple subagents use the browser, tab switching by one agent changes the active tab for all agents. Per-tab data (console buffers, network traces, dialogs, page info cache) is isolated — switching tabs does not destroy another agent's collected data. Call \`browser_current_tab\` before mutation tools to confirm you're on the expected tab. If \`browser_page_info\` returns a dialog, handle it with \`browser_handle_dialog\` promptly — dialogs block page interaction and are not queued across agents. +Each tool carries its own detailed guidelines — this table is only for routing. -### Common Patterns +### Workflows -**Navigation:** +**Research (search → read):** \`\`\` -browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot() +browser_web_search("query") // ranked links, no page content +→ browser_read_page(url) // clean article text for the top hits +// fall back to browser_open_urls + browser_execute_js only when reader mode misses \`\`\` -**Form filling (ref-first — this is the reliable path on React/SPA forms):** +**Form filling (ref-first — the reliable path on SPA forms):** \`\`\` -browser_wait_for({ selector: "#email" }) // ensure SPA field is rendered -browser_snapshot() // each interactive element gets a [eN] ref -→ browser_fill({ ref: "e7", value: "a@b.com" }) // target by ref, not a guessed selector -→ browser_select_option({ ref: "e9", label: "India" }) // native <select> by ref -→ browser_click({ ref: "e12" }) // e.g. Save — re-resolves position even if the form reflowed -// each mutating call appends a compact "Page changes" diff — read it to confirm -// the change landed (e.g. a save closed the form, a new field appeared as *[eN]). +browser_wait_for({ selector: "#email" }) // ensure the field is rendered +browser_snapshot() // interactive elements get [eN] refs +→ browser_fill({ ref: "e7", value: "a@b.com" }) +→ browser_select_option({ ref: "e9", label: "India" }) +→ browser_click({ ref: "e12" }) // re-resolves position even after reflow +// read each "Page changes" diff to confirm the step landed \`\`\` -**Why ref over selector/coordinates:** refs are keyed to the element's identity, so -they survive the re-renders that React/Vue forms trigger on every edit and save — -coordinates go stale and selectors you guess often don't match. If a call returns -"ref is stale", the page changed: re-run browser_snapshot for fresh refs. - -Prefer browser_fill for text inputs/textareas — it fires input/change so controlled -components update, and returns the field's value for verification. Use browser_click + -browser_type only for keystroke-sensitive widgets (autocomplete, masked inputs); -browser_type requires an already-focused field and errors if none is focused. -**Data extraction:** +**Navigate + verify:** \`\`\` -browser_execute_js("document.querySelector('.price').innerText") -// or for APIs: -browser_http_get("https://api.example.com/data") +browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot() \`\`\` -**Scrolling:** -\`\`\` -browser_screenshot() → browser_scroll({ deltaY: 500 }) → browser_screenshot() -\`\`\` -Note: deltaY follows W3C wheel-event convention: positive=down, negative=up. Default deltaY=300 scrolls down. +**Scroll:** \`browser_scroll({ deltaY: 500 })\` — W3C wheel convention: positive = down, +negative = up (default 300, down). -**Research Workflow (search + browser):** +### Parallelism + +Observation tools (\`browser_screenshot\`, \`browser_page_info\`, \`browser_execute_js\`, +\`browser_list_tabs\`, \`browser_http_get\`, \`browser_web_search\`, \`browser_read_page\`, …) +run in parallel with each other and with mutations. The harness serializes mutations +(click, type, scroll, navigate, switch_tab) so they never race. Emit independent calls +in one turn: \`\`\` -browser_navigate("https://google.com/search?q=...") → search engine in a tab -browser_open_urls(urls: ["url1", "url2", ...]) → open result pages in parallel tabs -browser_list_tabs() → see all open tabs with targetIds -browser_switch_tab(targetId: "...") → switch to a tab -browser_screenshot() → visually inspect the page -browser_execute_js("document.querySelector('.main').innerText") → extract content +browser_screenshot() + browser_page_info() + browser_execute_js("document.title") \`\`\` -**Temporary Scripts (extending the harness):** +**Multi-agent:** tab switching by one agent changes the active tab for all agents, +but per-tab data (console, network, dialogs) stays isolated. Call \`browser_current_tab\` +before a mutation to confirm you're on the expected tab. Handle any dialog reported by +\`browser_page_info\` promptly — dialogs block interaction and aren't queued across agents. + +**Session boundary & tab hygiene:** you operate only inside this session's own Chrome +window. \`browser_switch_tab\`/\`browser_close_tab\` refuse tabs this session didn't open, +and \`browser_list_tabs\` defaults to owned tabs — never reach into the user's other tabs +or windows. Close tabs when you're done with them: call \`browser_close_tab\` on any tab +you opened (via \`browser_new_tab\`/\`browser_open_urls\`) once you've extracted what you +need. Don't leave a pile of stale tabs behind — keep only what a later step still needs. +(Leftover tabs are closed automatically when the session ends, but close them yourself +as you go.) + +### Extending: temporary scripts + +When built-in tools can't express a multi-step workflow, write a script to a temp file +and run it with \`browser_run_script\`. It executes in the harness process with direct +daemon + Node access. \`\`\` -write("/tmp/extract.js", "...script with daemon access...") → browser_run_script("/tmp/extract.js") +write(<tempfile>.js, \` + const results = []; + for (const url of params.urls) { + await daemon.cdp("Page.navigate", { url }); + await new Promise(r => setTimeout(r, 2000)); + results.push({ url, title: await daemon.evaluateJS("document.title") }); + } + return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; +\`) +browser_run_script(<tempfile>.js, { urls: [...] }) \`\`\` - -### Additional Tools - -| Tool | Purpose | -|------|---------| -| browser_upload_file | Upload a file to a file input by **ref** (preferred) or selector (bypasses file picker) | -| browser_dispatch_key | Dispatch a DOM KeyboardEvent on an element by **ref** (preferred) or selector (for React/Vue inputs); returns details.matched | -| browser_download | Configure download directory and disable save-as prompts | -| browser_viewport_resize | Resize the viewport for responsive testing | -| browser_drag_and_drop | Perform drag-and-drop from one coordinate to another | -| browser_print_to_pdf | Print the current page to a PDF file | -| browser_get_network_log | Get buffered network request/response events | +Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON, +Buffer, setTimeout, clearTimeout. Use a real temp path from the OS — don't hardcode \`/tmp\`. `; } diff --git a/src/state.ts b/src/state.ts index 0629f40..d4b1534 100644 --- a/src/state.ts +++ b/src/state.ts @@ -12,6 +12,7 @@ export type BrowserState = { readonly remoteBrowserId?: string; readonly ownedTargetIds?: ReadonlyArray<string>; readonly harnessWindowTargetId?: string; + readonly harnessWindowId?: number; }; export const defaultState = (namespace = "default"): BrowserState => ({ |
