pi-browser-harness 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ import { Type } from "typebox";
2
+ import type { BrowserClient } from "../client";
3
+ import { type Result, err, ok } from "../util/result";
4
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
+
6
+ // ── Page-side functions ──────────────────────────────────────────────────────
7
+ // These run inside the page via Runtime.callFunctionOn with `this` bound to the
8
+ // resolved element. They are NOT string-interpolated with user data — the value
9
+ // is passed as a structured `arguments` entry, so safeJs is unnecessary here.
10
+
11
+ /**
12
+ * Universal field setter. Auto-detects the element type and applies the right
13
+ * strategy, then reads the resulting value back so the caller can confirm.
14
+ *
15
+ * The <input>/<textarea> branch uses the "native setter" trick: React (and
16
+ * other frameworks) overload the element's `value` setter to track state, so a
17
+ * plain `el.value = x` updates the DOM but not the framework. Calling the
18
+ * prototype's original setter and then dispatching a bubbling `input` event is
19
+ * what makes React's onChange fire.
20
+ */
21
+ const FILL_FN = `
22
+ function(value) {
23
+ const el = this;
24
+ if (!el || el.nodeType !== 1) return { ok: false, reason: "ref does not point to an element" };
25
+ if (el.disabled) return { ok: false, reason: "element is disabled" };
26
+ const tag = (el.tagName || "").toLowerCase();
27
+ const type = (el.type || "").toLowerCase();
28
+ try { el.focus(); } catch (e) {}
29
+ const fire = function(t) { el.dispatchEvent(new Event(t, { bubbles: true })); };
30
+
31
+ if (tag === "select") {
32
+ const want = String(value);
33
+ let matched = null;
34
+ for (let i = 0; i < el.options.length; i++) {
35
+ const o = el.options[i];
36
+ if (o.value === want || o.label === want || o.text === want) { matched = o; break; }
37
+ }
38
+ if (!matched) {
39
+ const opts = [];
40
+ for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text });
41
+ return { ok: false, reason: "no matching option", kind: "select", options: opts };
42
+ }
43
+ el.value = matched.value;
44
+ fire("input"); fire("change");
45
+ return { ok: true, kind: "select", value: el.value, text: matched.text };
46
+ }
47
+
48
+ if (tag === "input" && (type === "checkbox" || type === "radio")) {
49
+ const want = value === true || value === "true" || value === "on" || value === 1;
50
+ if (el.checked !== want) {
51
+ try { el.click(); } catch (e) {}
52
+ if (el.checked !== want) { el.checked = want; fire("input"); fire("change"); }
53
+ }
54
+ return { ok: true, kind: type, checked: el.checked };
55
+ }
56
+
57
+ if (el.isContentEditable) {
58
+ el.textContent = String(value);
59
+ fire("input"); fire("change");
60
+ return { ok: true, kind: "contenteditable", value: el.textContent };
61
+ }
62
+
63
+ if (tag === "input" || tag === "textarea") {
64
+ const proto = tag === "textarea" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
65
+ const desc = Object.getOwnPropertyDescriptor(proto, "value");
66
+ if (desc && desc.set) desc.set.call(el, String(value));
67
+ else el.value = String(value);
68
+ fire("input"); fire("change");
69
+ return { ok: true, kind: tag, value: el.value };
70
+ }
71
+
72
+ return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")" };
73
+ }`;
74
+
75
+ /** Specialized <select> setter — matches by value first, then by label/text. */
76
+ const SELECT_FN = `
77
+ function(value, label) {
78
+ const el = this;
79
+ if (!el || (el.tagName || "").toLowerCase() !== "select") return { ok: false, reason: "ref is not a <select> element" };
80
+ if (el.disabled) return { ok: false, reason: "select is disabled" };
81
+ let matched = null;
82
+ for (let i = 0; i < el.options.length; i++) {
83
+ const o = el.options[i];
84
+ if (value != null && o.value === String(value)) { matched = o; break; }
85
+ if (label != null && (o.text === String(label) || o.label === String(label))) { matched = o; break; }
86
+ }
87
+ if (!matched) {
88
+ const opts = [];
89
+ for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text });
90
+ return { ok: false, reason: "no matching option", options: opts };
91
+ }
92
+ try { el.focus(); } catch (e) {}
93
+ el.value = matched.value;
94
+ el.dispatchEvent(new Event("input", { bubbles: true }));
95
+ el.dispatchEvent(new Event("change", { bubbles: true }));
96
+ return { ok: true, value: el.value, text: matched.text };
97
+ }`;
98
+
99
+ /** Specialized checkbox/radio setter — only acts when the state differs. */
100
+ const CHECK_FN = `
101
+ function(checked) {
102
+ const el = this;
103
+ const tag = (el.tagName || "").toLowerCase();
104
+ const type = (el.type || "").toLowerCase();
105
+ if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" };
106
+ if (el.disabled) return { ok: false, reason: "element is disabled" };
107
+ const want = checked === true;
108
+ let changed = false;
109
+ if (el.checked !== want) {
110
+ try { el.click(); } catch (e) {}
111
+ if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); }
112
+ changed = true;
113
+ }
114
+ return { ok: true, checked: el.checked, changed: changed };
115
+ }`;
116
+
117
+ // ── Resolver ─────────────────────────────────────────────────────────────────
118
+
119
+ /** Shape returned by the page-side functions above. */
120
+ type PageResult = {
121
+ readonly ok: boolean;
122
+ readonly reason?: string;
123
+ readonly kind?: string;
124
+ readonly value?: unknown;
125
+ readonly text?: string;
126
+ readonly checked?: boolean;
127
+ readonly changed?: boolean;
128
+ readonly options?: ReadonlyArray<{ value: string; text: string }>;
129
+ };
130
+
131
+ /**
132
+ * Resolve a ref (CDP backendDOMNodeId) to a live JS handle and invoke a
133
+ * page-side function on it. DOM + Runtime are enabled on every session
134
+ * (see cdp/session.ts enableDomains), so no extra setup is required.
135
+ */
136
+ const resolveAndCall = async (
137
+ client: BrowserClient,
138
+ ref: number,
139
+ functionDeclaration: string,
140
+ args: ReadonlyArray<unknown>,
141
+ ): Promise<Result<PageResult, ToolErr>> => {
142
+ const session = client.session();
143
+
144
+ const resolved = await session.call("DOM.resolveNode", { backendNodeId: ref });
145
+ if (!resolved.success) {
146
+ return err({
147
+ kind: "invalid_state",
148
+ message: `ref ${ref} could not be resolved — the page may have changed. Re-run browser_snapshot to get fresh refs.`,
149
+ details: { ref },
150
+ });
151
+ }
152
+ const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
153
+ if (objectId === undefined) {
154
+ return err({
155
+ kind: "invalid_state",
156
+ message: `ref ${ref} resolved to no JS object — re-run browser_snapshot to get fresh refs.`,
157
+ details: { ref },
158
+ });
159
+ }
160
+
161
+ try {
162
+ const called = await session.call("Runtime.callFunctionOn", {
163
+ objectId,
164
+ functionDeclaration,
165
+ arguments: args.map((v) => ({ value: v })),
166
+ returnByValue: true,
167
+ awaitPromise: true,
168
+ });
169
+ if (!called.success) return err({ kind: "cdp_error", message: called.error.message, details: { ref } });
170
+ const data = called.data as { result?: { value?: unknown }; exceptionDetails?: unknown };
171
+ if (data.exceptionDetails !== undefined) {
172
+ return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(data.exceptionDetails)}`, details: { ref } });
173
+ }
174
+ const value = data.result?.value;
175
+ if (typeof value !== "object" || value === null) {
176
+ return err({ kind: "internal", message: "page function returned no result object", details: { ref } });
177
+ }
178
+ return ok(value as PageResult);
179
+ } finally {
180
+ // Best-effort: release the remote handle so it can be GC'd. Failure is
181
+ // harmless (the page may have navigated), so we ignore the result.
182
+ await session.call("Runtime.releaseObject", { objectId });
183
+ }
184
+ };
185
+
186
+ /** Drop the `ok` flag before stashing a page result in tool `details` — the
187
+ * tool-result envelope reserves `ok` for its own success discriminant. */
188
+ const detailsOf = (ref: number, res: PageResult, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
189
+ const { ok: _ok, ...rest } = res;
190
+ return { ref, ...rest, ...(extra ?? {}) };
191
+ };
192
+
193
+ // ── Tools ────────────────────────────────────────────────────────────────────
194
+
195
+ const FieldValue = Type.Union([Type.String(), Type.Boolean()], {
196
+ description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.",
197
+ });
198
+
199
+ const FillArgs = Type.Object({
200
+ ref: Type.Integer({ description: "Element ref from browser_snapshot ([ref=N])." }),
201
+ value: FieldValue,
202
+ });
203
+
204
+ export const fillTool = defineBrowserTool({
205
+ name: "browser_fill",
206
+ label: "Browser Fill",
207
+ description:
208
+ "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].",
209
+ promptSnippet: "Fill a form field by ref (text, select, checkbox) — React-safe, self-confirming",
210
+ promptGuidelines: [
211
+ "Get the ref from browser_snapshot's [ref=N] for the target field. No click-to-focus needed.",
212
+ "value is a string for text fields/selects/contenteditable, or a boolean for checkboxes/radios.",
213
+ "The result reports the value the field actually holds after filling — check it matches what you intended.",
214
+ "For autocomplete/typeahead that needs real keystrokes, click the field's @(x,y) then use browser_type instead.",
215
+ "If a ref no longer resolves, re-run browser_snapshot to get fresh refs.",
216
+ ],
217
+ parameters: FillArgs,
218
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
219
+ const r = await resolveAndCall(client, args.ref, FILL_FN, [args.value]);
220
+ if (!r.success) return r;
221
+ const res = r.data;
222
+ if (!res.ok) {
223
+ return err({ kind: "invalid_state", message: `Could not fill ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
224
+ }
225
+ const wanted = typeof args.value === "boolean" ? undefined : String(args.value);
226
+ const got = res.value !== undefined ? String(res.value) : undefined;
227
+ const mismatch = wanted !== undefined && got !== undefined && got !== wanted;
228
+ let text: string;
229
+ if (mismatch) {
230
+ text = `Filled ref ${args.ref}, but it now reads ${JSON.stringify(got)} (wanted ${JSON.stringify(wanted)})`;
231
+ } else if (got !== undefined) {
232
+ text = `Filled ref ${args.ref} = ${JSON.stringify(got)}`;
233
+ } else if (res.checked !== undefined) {
234
+ text = `Filled ref ${args.ref} (checked=${res.checked})`;
235
+ } else {
236
+ text = `Filled ref ${args.ref}`;
237
+ }
238
+ return ok({ text, details: detailsOf(args.ref, res, { mismatch }) });
239
+ },
240
+ });
241
+
242
+ const FillFormArgs = Type.Object({
243
+ fields: Type.Array(
244
+ Type.Object({
245
+ ref: Type.Integer({ description: "Element ref from browser_snapshot ([ref=N])." }),
246
+ value: FieldValue,
247
+ }),
248
+ { minItems: 1, description: "Fields to fill in one batch, each by ref." },
249
+ ),
250
+ });
251
+
252
+ export const fillFormTool = defineBrowserTool({
253
+ name: "browser_fill_form",
254
+ label: "Browser Fill Form",
255
+ description:
256
+ "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.",
257
+ promptSnippet: "Fill many form fields at once by ref — React-safe, self-confirming",
258
+ promptGuidelines: [
259
+ "Preferred for forms: snapshot once, then fill all fields in a single browser_fill_form call.",
260
+ "Each field: { ref, value }. value is a string for text/select/contenteditable, boolean for checkbox/radio.",
261
+ "The result summarizes how many fields filled cleanly and flags any value mismatches or per-field errors.",
262
+ "Re-run browser_snapshot afterwards to verify the form state if needed.",
263
+ ],
264
+ parameters: FillFormArgs,
265
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
266
+ const results: Array<Readonly<Record<string, unknown>>> = [];
267
+ let okCount = 0;
268
+ const issues: string[] = [];
269
+ for (const f of args.fields) {
270
+ const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]);
271
+ if (!r.success) {
272
+ results.push({ ref: f.ref, ok: false, error: r.error.message });
273
+ issues.push(`ref ${f.ref} (${r.error.message})`);
274
+ continue;
275
+ }
276
+ const res = r.data;
277
+ const wanted = typeof f.value === "boolean" ? undefined : String(f.value);
278
+ const got = res.value !== undefined ? String(res.value) : undefined;
279
+ const mismatch = wanted !== undefined && got !== undefined && got !== wanted;
280
+ const fieldOk = res.ok === true && !mismatch;
281
+ if (fieldOk) okCount++;
282
+ else issues.push(`ref ${f.ref} (${res.ok === false ? (res.reason ?? "failed") : "value mismatch"})`);
283
+ results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
284
+ }
285
+ const text =
286
+ `Filled ${okCount}/${args.fields.length} fields` +
287
+ (issues.length > 0 ? `; issues: ${issues.join(", ")}` : "");
288
+ return ok({ text, details: { results } });
289
+ },
290
+ });
291
+
292
+ const SelectOptionArgs = Type.Object({
293
+ ref: Type.Integer({ description: "Element ref of a <select> from browser_snapshot ([ref=N])." }),
294
+ value: Type.Optional(Type.String({ description: "Option value attribute to select." })),
295
+ label: Type.Optional(Type.String({ description: "Visible option text/label to select (used if value is omitted)." })),
296
+ });
297
+
298
+ export const selectOptionTool = defineBrowserTool({
299
+ name: "browser_select_option",
300
+ label: "Browser Select Option",
301
+ description:
302
+ "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].",
303
+ promptSnippet: "Choose an option in a <select> dropdown by ref",
304
+ promptGuidelines: [
305
+ "Provide value (the option's value attribute) or label (the visible text). value wins if both given.",
306
+ "On no match, the result lists the available options so you can retry with a valid one.",
307
+ "Use this for native <select> elements — clicking coordinates does not work for OS dropdowns.",
308
+ ],
309
+ parameters: SelectOptionArgs,
310
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
311
+ if (args.value === undefined && args.label === undefined) {
312
+ return err({ kind: "invalid_state", message: "Provide value or label to select an option." });
313
+ }
314
+ const r = await resolveAndCall(client, args.ref, SELECT_FN, [args.value ?? null, args.label ?? null]);
315
+ if (!r.success) return r;
316
+ const res = r.data;
317
+ if (!res.ok) {
318
+ return err({ kind: "invalid_state", message: `Could not select option on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
319
+ }
320
+ return ok({
321
+ text: `Selected ${JSON.stringify(res.text)} (value=${JSON.stringify(res.value)}) on ref ${args.ref}`,
322
+ details: detailsOf(args.ref, res),
323
+ });
324
+ },
325
+ });
326
+
327
+ const SetCheckedArgs = Type.Object({
328
+ ref: Type.Integer({ description: "Element ref of a checkbox or radio from browser_snapshot ([ref=N])." }),
329
+ checked: Type.Boolean({ description: "Desired checked state." }),
330
+ });
331
+
332
+ export const setCheckedTool = defineBrowserTool({
333
+ name: "browser_set_checked",
334
+ label: "Browser Set Checked",
335
+ description:
336
+ "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].",
337
+ promptSnippet: "Set a checkbox/radio checked state by ref",
338
+ promptGuidelines: [
339
+ "checked:true ticks the box, checked:false unticks it. Idempotent — no-op if already in the desired state.",
340
+ "For radios, set checked:true on the option you want; the group deselects the others.",
341
+ ],
342
+ parameters: SetCheckedArgs,
343
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
344
+ const r = await resolveAndCall(client, args.ref, CHECK_FN, [args.checked]);
345
+ if (!r.success) return r;
346
+ const res = r.data;
347
+ if (!res.ok) {
348
+ return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
349
+ }
350
+ return ok({
351
+ text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed === false ? " (already)" : ""),
352
+ details: detailsOf(args.ref, res),
353
+ });
354
+ },
355
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Isolated-attach tab lifecycle (ADR-0002 v1). Opens a harness-owned tab,
3
+ * attaches to it by its own CDP sessionId, and drives it via callOnTarget /
4
+ * evaluateJs(expr, sid) WITHOUT ever touching the global "current" tab — so a
5
+ * search or page-read never disturbs the tab the user is looking at, even when
6
+ * several subagents run concurrently.
7
+ *
8
+ * Mirrors the create/attach sequence in browser_open_urls
9
+ * (src/domains/navigate.ts:84-139). Every page evaluation is bounded by a
10
+ * timeout, so a JS dialog on the (uninstrumented) isolated tab surfaces as a
11
+ * typed timeout rather than hanging — see the dialog-hang caveat in plan.md.
12
+ */
13
+ import type { BrowserClient } from "../client";
14
+ import { type Result, err, ok } from "../util/result";
15
+ import { safeJs } from "../util/js-template";
16
+ import { bindHarnessWindowId } from "../cdp/window-binding";
17
+ import type { ToolErr } from "../util/tool";
18
+
19
+ /** A tab attached by its own sessionId, ready to drive via callOnTarget. */
20
+ export type IsolatedTab = {
21
+ readonly targetId: string;
22
+ readonly sessionId: string;
23
+ };
24
+
25
+ const EVAL_TIMEOUT_MS = 15_000;
26
+ const READY_POLL_INTERVAL_MS = 50;
27
+
28
+ const toToolErr = (message: string, kind: ToolErr["kind"] = "cdp_error"): ToolErr => ({ kind, message });
29
+
30
+ /**
31
+ * Open a harness-owned tab and attach to it, returning its targetId + private
32
+ * sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done.
33
+ */
34
+ export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => {
35
+ const hw = client.ownership().harnessWindow();
36
+ const createParams: Record<string, unknown> = hw
37
+ ? { url: "about:blank", openerId: hw }
38
+ : { url: "about:blank", newWindow: true };
39
+
40
+ const created = await client.session().callBrowser("Target.createTarget", createParams);
41
+ if (!created.success) return err(toToolErr(created.error.message));
42
+ const { targetId } = created.data as { targetId: string };
43
+
44
+ if (createParams["newWindow"]) {
45
+ client.ownership().setHarnessWindow(targetId);
46
+ await bindHarnessWindowId(client, targetId);
47
+ }
48
+ client.ownership().add(targetId);
49
+
50
+ const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
51
+ if (!attached.success) {
52
+ await client.closeTab(targetId);
53
+ return err(toToolErr(attached.error.message));
54
+ }
55
+ const { sessionId } = attached.data as { sessionId: string };
56
+
57
+ const enabled = await client.session().callOnTarget("Page.enable", {}, sessionId);
58
+ if (!enabled.success) {
59
+ await client.closeTab(targetId);
60
+ return err(toToolErr(enabled.error.message));
61
+ }
62
+ return ok({ targetId, sessionId });
63
+ };
64
+
65
+ /** Navigate an isolated tab to a URL (does not wait for load). */
66
+ export const navigateIsolatedTab = async (
67
+ client: BrowserClient,
68
+ tab: IsolatedTab,
69
+ url: string,
70
+ ): Promise<Result<void, ToolErr>> => {
71
+ const nav = await client.session().callOnTarget("Page.navigate", { url }, tab.sessionId, {
72
+ timeoutMs: EVAL_TIMEOUT_MS,
73
+ });
74
+ if (!nav.success) return err(toToolErr(nav.error.message));
75
+ return ok(undefined);
76
+ };
77
+
78
+ /** Poll the isolated tab until document.readyState === 'complete' or timeout. */
79
+ export const waitForIsolatedLoad = async (
80
+ client: BrowserClient,
81
+ tab: IsolatedTab,
82
+ timeoutMs: number,
83
+ signal?: AbortSignal,
84
+ ): Promise<Result<void, ToolErr>> => {
85
+ const start = Date.now();
86
+ while (Date.now() - start < timeoutMs) {
87
+ if (signal?.aborted) return err(toToolErr("aborted", "internal"));
88
+ const r = await client.evaluateJs(safeJs`document.readyState`, tab.sessionId);
89
+ if (r.success && r.data === "complete") return ok(undefined);
90
+ await new Promise((res) => setTimeout(res, READY_POLL_INTERVAL_MS));
91
+ }
92
+ return err(toToolErr(`page did not finish loading in ${Math.round(timeoutMs / 1000)}s`, "timeout"));
93
+ };
94
+
95
+ /**
96
+ * Evaluate an expression in the isolated tab and return the raw value. Bounds
97
+ * the eval with a timeout so a blocking dialog cannot hang the tool.
98
+ */
99
+ export const evalInIsolatedTab = async (
100
+ client: BrowserClient,
101
+ tab: IsolatedTab,
102
+ expression: string,
103
+ ): Promise<Result<unknown, ToolErr>> => {
104
+ const r = await client.session().callOnTarget(
105
+ "Runtime.evaluate",
106
+ { expression, returnByValue: true, awaitPromise: true },
107
+ tab.sessionId,
108
+ { timeoutMs: EVAL_TIMEOUT_MS },
109
+ );
110
+ if (!r.success) return err(toToolErr(r.error.message, r.error.kind === "timeout" ? "timeout" : "cdp_error"));
111
+ const data = r.data as { result?: { value?: unknown }; exceptionDetails?: unknown };
112
+ if (data.exceptionDetails) {
113
+ return err(toToolErr(`page evaluation failed: ${JSON.stringify(data.exceptionDetails)}`));
114
+ }
115
+ return ok(data.result?.value);
116
+ };
117
+
118
+ /** Close an isolated tab and drop its ownership. Best-effort; never throws. */
119
+ export const closeIsolatedTab = async (client: BrowserClient, tab: IsolatedTab): Promise<void> => {
120
+ await client.closeTab(tab.targetId).catch(() => {});
121
+ };
@@ -4,6 +4,7 @@ import { type Result, err, ok } from "../util/result";
4
4
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
5
  import { applyTruncation } from "../util/truncate";
6
6
  import { safeJs } from "../util/js-template";
7
+ import { bindHarnessWindowId } from "../cdp/window-binding";
7
8
 
8
9
  const NavigateArgs = Type.Object({
9
10
  url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
@@ -75,7 +76,7 @@ export const openUrlsTool = defineBrowserTool({
75
76
  description: "Open multiple URLs in parallel new tabs. Returns per-URL outcomes.",
76
77
  promptSnippet: "Open multiple URLs in new tabs (parallel)",
77
78
  promptGuidelines: [
78
- "Use after web_search to open citations in parallel.",
79
+ "Use after browser_web_search to open citations in parallel.",
79
80
  "After opening, use browser_list_tabs / browser_switch_tab to navigate, then browser_snapshot to inspect each tab.",
80
81
  "Use browser_wait_for_load on a tab before extracting data from SPAs.",
81
82
  ],
@@ -83,17 +84,31 @@ export const openUrlsTool = defineBrowserTool({
83
84
  renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
84
85
  async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
85
86
  const total = args.urls.length;
86
- // Phase 1: create tabs in parallel. Own them so they appear in
87
- // browser_list_tabs scope:'owned' and group in the harness window.
88
- const hw = client.ownership().harnessWindow();
89
- const created = await Promise.all(args.urls.map(async (url): Promise<TabResult> => {
90
- const params: Record<string, unknown> = { url: "about:blank" };
91
- if (hw) params["openerId"] = hw;
92
- else params["newWindow"] = true;
93
- const r = await client.session().callBrowser("Target.createTarget", params);
87
+ // Phase 1: create tabs. All must land in ONE window. If the harness window
88
+ // already exists, open every url as its child in parallel. If it doesn't
89
+ // (fresh/closed), create the FIRST url as a new dedicated window (binding
90
+ // its windowId) and open the rest as children of it — a parallel
91
+ // newWindow:true per url would otherwise scatter tabs across N windows.
92
+ let seed = client.ownership().harnessWindow();
93
+ let seedResult: TabResult | undefined;
94
+ if (!seed) {
95
+ const url0 = args.urls[0];
96
+ if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" });
97
+ const r0 = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
98
+ if (!r0.success) return err({ kind: "cdp_error", message: r0.error.message });
99
+ seed = (r0.data as { targetId: string }).targetId;
100
+ client.ownership().setHarnessWindow(seed);
101
+ await bindHarnessWindowId(client, seed);
102
+ client.ownership().add(seed);
103
+ seedResult = { url: url0, targetId: seed, ok: true };
104
+ }
105
+ const seededWindow = seed;
106
+ const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => {
107
+ // Reuse the seed tab (created above) for the first url instead of duplicating it.
108
+ if (i === 0 && seedResult) return seedResult;
109
+ const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank", openerId: seededWindow });
94
110
  if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
95
111
  const c = r.data as { targetId: string };
96
- if (params["newWindow"]) client.ownership().setHarnessWindow(c.targetId);
97
112
  client.ownership().add(c.targetId);
98
113
  return { url, targetId: c.targetId, ok: true };
99
114
  }));
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The JavaScript source evaluated in a loaded page to capture candidate content
3
+ * blocks + metadata for the pure readability extractor (readability.ts). Crosses
4
+ * the CDP boundary via Runtime.evaluate (returnByValue) and returns a JSON
5
+ * string shaped like PageCapture.
6
+ *
7
+ * The DOM walk lives here (unavoidable); all scoring/selection is pure and lives
8
+ * in readability.ts, so the decision logic stays unit-testable.
9
+ */
10
+
11
+ /** Max bytes of body-text fallback captured for thin/structure-less pages. */
12
+ const BODY_TEXT_LIMIT = 20_000;
13
+
14
+ /** Build the capture expression. No interpolated values ⇒ no injection surface. */
15
+ export const buildPageCaptureExpr = (): string => `
16
+ (() => {
17
+ const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']);
18
+ const inBoilerplate = (el) => {
19
+ for (let n = el; n; n = n.parentElement) {
20
+ if (BOILERPLATE.has(n.tagName)) return true;
21
+ const role = n.getAttribute && n.getAttribute('role');
22
+ if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true;
23
+ }
24
+ return false;
25
+ };
26
+ const kindOf = (tag) => {
27
+ if (/^H[1-6]$/.test(tag)) return 'heading';
28
+ if (tag === 'LI') return 'listitem';
29
+ if (tag === 'BLOCKQUOTE') return 'blockquote';
30
+ if (tag === 'P') return 'paragraph';
31
+ return 'other';
32
+ };
33
+ const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote';
34
+ const root = document.querySelector('article, main, [role=main]') || document.body;
35
+ const blocks = [];
36
+ for (const el of root.querySelectorAll(selector)) {
37
+ const text = (el.innerText || '').replace(/\\s+/g, ' ').trim();
38
+ if (!text) continue;
39
+ let linkTextLength = 0;
40
+ for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length;
41
+ blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) });
42
+ }
43
+ const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT});
44
+ return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText });
45
+ })()
46
+ `;