pi-browser-harness 0.10.2 → 0.12.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +375 -265
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -37
  28. package/src/domains/console.ts +48 -4
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
@@ -2,8 +2,11 @@ import { Type } from "typebox";
2
2
  import { Markdown, Text } from "@mariozechner/pi-tui";
3
3
  import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
4
4
  import { type Result, err, ok } from "../util/result";
5
+ import { formatBytes } from "../util/truncate";
5
6
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
+ import { cdpCall } from "./cdp-call";
6
8
  import { applyTruncation } from "../util/truncate";
9
+ import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards";
7
10
  import type { NetworkRecord } from "../cdp/network-buffer";
8
11
 
9
12
  const HttpGetArgs = Type.Object({
@@ -21,10 +24,10 @@ export const httpGetTool = defineBrowserTool({
21
24
  "Fetch a URL outside the browser (faster than browser_navigate for APIs and static pages). Timeout covers headers AND body read.",
22
25
  promptSnippet: "HTTP GET (outside browser; for APIs/static pages)",
23
26
  promptGuidelines: [
24
- "Faster than navigate+execute_js for JSON/HTML APIs.",
25
27
  "No JS rendering — for SPAs use browser_navigate.",
26
28
  ],
27
29
  parameters: HttpGetArgs,
30
+ concurrency: "parallel",
28
31
  ensureAlive: false,
29
32
  async handler(args): Promise<Result<ToolOk, ToolErr>> {
30
33
  const ac = new AbortController();
@@ -96,11 +99,6 @@ const renderNetworkMarkdown = (
96
99
 
97
100
  const failed = records.filter((r) => r.failed || (r.status !== undefined && r.status >= 500)).length;
98
101
  const totalBytes = records.reduce((s, r) => s + (r.responseBodySize ?? 0), 0);
99
- const formatBytes = (n: number): string => {
100
- if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
101
- if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
102
- return `${n} B`;
103
- };
104
102
  const showWindow = (() => {
105
103
  const finished = records.filter((r) => r.durationMs !== undefined);
106
104
  if (finished.length === 0) return "";
@@ -129,7 +127,6 @@ const renderNetworkMarkdown = (
129
127
  return `${headerLines.join("\n")}\n\n${rows}${more}`;
130
128
  }
131
129
 
132
- // Expanded: full table + optional body sections.
133
130
  const tableHeader = `| # | Method | Status | URL | Type | ms |\n|---|---|---|---|---|---|`;
134
131
  const tableRows = records
135
132
  .map((r, i) => {
@@ -167,6 +164,57 @@ type NetworkDetails = {
167
164
  includeBodies: boolean;
168
165
  };
169
166
 
167
+ const asNetworkRecord = (v: unknown): NetworkRecord | undefined => {
168
+ const o = asRecord(v);
169
+ if (o === undefined) return undefined;
170
+ const method = asString(o["method"]);
171
+ const url = asString(o["url"]);
172
+ const type = asString(o["type"]);
173
+ if (method === undefined || url === undefined || type === undefined) return undefined;
174
+ const status = asNumber(o["status"]);
175
+ const statusText = asString(o["statusText"]);
176
+ const mimeType = asString(o["mimeType"]);
177
+ const durationMs = asNumber(o["durationMs"]);
178
+ const responseBodySize = asNumber(o["responseBodySize"]);
179
+ const failed = asBoolean(o["failed"]);
180
+ const errorText = asString(o["errorText"]);
181
+ const bodyRaw = o["body"];
182
+ const body = typeof bodyRaw === "string" || bodyRaw === null ? bodyRaw : undefined;
183
+ return {
184
+ requestId: asString(o["requestId"]) ?? "",
185
+ method,
186
+ url,
187
+ type,
188
+ requestStartedMs: asNumber(o["requestStartedMs"]) ?? 0,
189
+ requestBodySize: asNumber(o["requestBodySize"]) ?? 0,
190
+ ...(status !== undefined ? { status } : {}),
191
+ ...(statusText !== undefined ? { statusText } : {}),
192
+ ...(mimeType !== undefined ? { mimeType } : {}),
193
+ ...(durationMs !== undefined ? { durationMs } : {}),
194
+ ...(responseBodySize !== undefined ? { responseBodySize } : {}),
195
+ ...(failed !== undefined ? { failed } : {}),
196
+ ...(errorText !== undefined ? { errorText } : {}),
197
+ ...(body !== undefined ? { body } : {}),
198
+ };
199
+ };
200
+
201
+ const asNetworkDetails = (v: unknown): NetworkDetails | undefined => {
202
+ const o = asRecord(v);
203
+ if (o === undefined) return undefined;
204
+ const requests = asArrayOf(o["requests"], asNetworkRecord);
205
+ if (requests === undefined) return undefined;
206
+ const fullOutputPath = asString(o["fullOutputPath"]);
207
+ return {
208
+ total: asNumber(o["total"]) ?? requests.length,
209
+ returned: asNumber(o["returned"]) ?? requests.length,
210
+ bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false,
211
+ truncated: asBoolean(o["truncated"]) ?? false,
212
+ requests,
213
+ includeBodies: asBoolean(o["includeBodies"]) ?? false,
214
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
215
+ };
216
+ };
217
+
170
218
  export const networkRequestsTool = defineBrowserTool({
171
219
  name: "browser_network_requests",
172
220
  label: "Browser Network Requests",
@@ -175,11 +223,11 @@ export const networkRequestsTool = defineBrowserTool({
175
223
  promptSnippet: "Inspect recent network requests on the current page",
176
224
  promptGuidelines: [
177
225
  "Buffer is reset on tab switch — only requests on the current tab since attach are visible.",
178
- 'urlPattern is a substring; wrap in slashes for regex (e.g. "/\\\\.json$/").',
179
- "includeResponseBodies:true costs an extra CDP call per matched request — use only when you need payloads.",
226
+ "Pass includeResponseBodies:true to browser_network_requests only when you need payloads.",
180
227
  "If bufferOverflowed:true in the result, older events were dropped (capacity 500 records/tab).",
181
228
  ],
182
229
  parameters: NetworkRequestsArgs,
230
+ concurrency: "parallel",
183
231
 
184
232
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
185
233
  const session = client.session();
@@ -197,12 +245,12 @@ export const networkRequestsTool = defineBrowserTool({
197
245
  const timer = setTimeout(() => ac.abort(), BODY_BUDGET_MS);
198
246
  const fills = drained.records.map(async (r) => {
199
247
  if (ac.signal.aborted) return;
200
- const bodyRes = await session.call("Network.getResponseBody", { requestId: r.requestId });
248
+ const bodyRes = await cdpCall(client, "Network.getResponseBody", { requestId: r.requestId });
201
249
  if (!bodyRes.success) {
202
250
  r.body = null;
203
251
  return;
204
252
  }
205
- const b = bodyRes.data as { body: string; base64Encoded?: boolean };
253
+ const b = bodyRes.data;
206
254
  let body = b.base64Encoded ? Buffer.from(b.body, "base64").toString("utf8") : b.body;
207
255
  if (body.length > PER_BODY_CAP) body = body.slice(0, PER_BODY_CAP) + "…";
208
256
  r.body = body;
@@ -215,7 +263,7 @@ export const networkRequestsTool = defineBrowserTool({
215
263
  includeBodies: args.includeResponseBodies ?? false,
216
264
  total: drained.total,
217
265
  bufferOverflowed: drained.bufferOverflowed,
218
- expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans
266
+ expanded: true,
219
267
  });
220
268
  const trunc = await applyTruncation(text, "network");
221
269
 
@@ -233,7 +281,7 @@ export const networkRequestsTool = defineBrowserTool({
233
281
  },
234
282
 
235
283
  renderResult(result, expanded, theme) {
236
- const details = result.details as NetworkDetails | undefined;
284
+ const details = asNetworkDetails(result.details);
237
285
  if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0);
238
286
 
239
287
  const md = renderNetworkMarkdown(details.requests, {
@@ -1,9 +1,26 @@
1
1
  import { Type } from "typebox";
2
2
  import { type Result, err, ok } from "../util/result";
3
3
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
- import { sleep } from "../util/time";
4
+ import { evalJs } from "./cdp-call";
5
5
  import { safeJs } from "../util/js-template";
6
6
 
7
+ const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
8
+ new Promise((resolve, reject) => {
9
+ if (signal?.aborted) {
10
+ reject(signal.reason ?? new Error("aborted"));
11
+ return;
12
+ }
13
+ const onAbort = (): void => {
14
+ clearTimeout(timer);
15
+ reject(signal?.reason ?? new Error("aborted"));
16
+ };
17
+ const timer = setTimeout(() => {
18
+ signal?.removeEventListener("abort", onAbort);
19
+ resolve();
20
+ }, ms);
21
+ signal?.addEventListener("abort", onAbort, { once: true });
22
+ });
23
+
7
24
  export const pageInfoTool = defineBrowserTool({
8
25
  name: "browser_page_info",
9
26
  label: "Browser Page Info",
@@ -11,12 +28,10 @@ export const pageInfoTool = defineBrowserTool({
11
28
  "Get current page state: URL, title, viewport size, scroll position, page dimensions. If a JS dialog is open, returns dialog info instead.",
12
29
  promptSnippet: "Get current page URL, title, viewport, and scroll position",
13
30
  promptGuidelines: [
14
- "Use browser_page_info to quickly check what page you're on and whether a JS dialog is blocking interaction.",
15
- "If browser_page_info returns a dialog, use browser_handle_dialog before any other browser actions.",
16
- "JS dialogs freeze the page's JS thread, so no other interaction works until the dialog is handled.",
17
- "browser_page_info auto-detects alert, confirm, prompt, and beforeunload dialogs.",
31
+ "If browser_page_info returns a dialog, call browser_handle_dialog before any other browser actions: dialogs freeze the page's JS thread.",
18
32
  ],
19
33
  parameters: Type.Object({}),
34
+ concurrency: "parallel",
20
35
  async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
21
36
  const r = await client.pageInfo();
22
37
  if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -46,6 +61,7 @@ export const waitTool = defineBrowserTool({
46
61
  promptSnippet: "Wait N seconds",
47
62
  promptGuidelines: ["Use sparingly — prefer browser_wait_for_load when waiting for a page load."],
48
63
  parameters: WaitArgs,
64
+ concurrency: "parallel",
49
65
  async handler(args, { signal }): Promise<Result<ToolOk, ToolErr>> {
50
66
  try {
51
67
  await sleep(Math.round(args.seconds * 1000), signal);
@@ -68,15 +84,15 @@ export const waitForLoadTool = defineBrowserTool({
68
84
  promptSnippet: "Wait for the page to finish loading",
69
85
  promptGuidelines: [
70
86
  "Call after browser_navigate / browser_open_urls before extracting data.",
71
- "Returns when readyState becomes 'complete' OR the timeout elapses.",
72
87
  ],
73
88
  parameters: WaitForLoadArgs,
89
+ concurrency: "serialized",
74
90
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
75
91
  const timeoutMs = (args.timeout ?? 15) * 1000;
76
92
  const start = Date.now();
77
93
  while (Date.now() - start < timeoutMs) {
78
94
  if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
79
- const r = await client.evaluateJs(safeJs`document.readyState`);
95
+ const r = await evalJs(client, safeJs`document.readyState`);
80
96
  if (r.success && r.data === "complete") {
81
97
  const ms = Date.now() - start;
82
98
  return ok({ text: `Page loaded in ${Math.round(ms / 100) / 10}s`, details: { ms } });
@@ -102,10 +118,9 @@ export const waitForTool = defineBrowserTool({
102
118
  promptSnippet: "Wait for an element or text to appear (or an element to disappear)",
103
119
  promptGuidelines: [
104
120
  "Use before browser_fill / browser_click on dynamic pages so you act after the element renders.",
105
- "Provide selector (default: wait until present; gone:true: wait until absent) or text.",
106
- "Returns a typed timeout error if the condition isn't met within the timeout.",
107
121
  ],
108
122
  parameters: WaitForArgs,
123
+ concurrency: "parallel",
109
124
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
110
125
  if (args.selector === undefined && args.text === undefined) {
111
126
  return err({ kind: "invalid_state", message: "Provide a selector or text to wait for." });
@@ -119,7 +134,7 @@ export const waitForTool = defineBrowserTool({
119
134
  const start = Date.now();
120
135
  while (Date.now() - start < timeoutMs) {
121
136
  if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
122
- const r = await client.evaluateJs(expr);
137
+ const r = await evalJs(client, expr);
123
138
  if (r.success && r.data === true) {
124
139
  const ms = Date.now() - start;
125
140
  const what = args.selector !== undefined ? `${args.selector}${gone ? " gone" : ""}` : `text "${args.text}"`;
@@ -1,17 +1,20 @@
1
1
  import { Type } from "typebox";
2
+ import { Compile } from "typebox/compile";
3
+ import { parseJson } from "../../schemas/parse";
2
4
  import { type Result, err, ok } from "../../util/result";
3
5
  import { applyTruncation } from "../../util/truncate";
4
6
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
7
+ import { cdpCallBrowser, evalJs } from "../cdp-call";
5
8
  import {
6
9
  closeIsolatedTab,
7
10
  evalInIsolatedTab,
11
+ isJsonText,
8
12
  type IsolatedTab,
9
13
  navigateIsolatedTab,
10
14
  openIsolatedTab,
11
15
  waitForIsolatedLoad,
12
16
  } from "../isolated-tab";
13
17
  import { renderExpandableText } from "../render";
14
- import { buildPageCaptureExpr } from "./capture";
15
18
  import { extractReadable, type PageCapture, type ReadablePage } from "./readability";
16
19
 
17
20
  const LOAD_TIMEOUT_MS = 15_000;
@@ -23,23 +26,74 @@ const ReadPageArgs = Type.Object({
23
26
  ),
24
27
  });
25
28
 
26
- const isPageCapture = (value: unknown): value is PageCapture => {
27
- if (typeof value !== "object" || value === null) return false;
28
- const v = value as Record<string, unknown>;
29
- return typeof v["url"] === "string" && typeof v["title"] === "string" && Array.isArray(v["blocks"]) && typeof v["bodyText"] === "string";
30
- };
29
+ const ContentBlockSchema = Type.Object({
30
+ kind: Type.Union([
31
+ Type.Literal("paragraph"),
32
+ Type.Literal("heading"),
33
+ Type.Literal("listitem"),
34
+ Type.Literal("blockquote"),
35
+ Type.Literal("other"),
36
+ ]),
37
+ text: Type.String(),
38
+ linkTextLength: Type.Number(),
39
+ inBoilerplate: Type.Boolean(),
40
+ });
31
41
 
32
- const safeParse = (text: string): unknown => {
33
- try {
34
- return JSON.parse(text) as unknown;
35
- } catch {
36
- return undefined;
42
+ const PageCaptureSchema = Type.Object({
43
+ url: Type.String(),
44
+ title: Type.String(),
45
+ blocks: Type.Array(ContentBlockSchema),
46
+ bodyText: Type.String(),
47
+ });
48
+
49
+ const pageCaptureValidator = Compile(PageCaptureSchema);
50
+
51
+ const BODY_TEXT_LIMIT = 20_000;
52
+
53
+ const buildPageCaptureExpr = (): string => `
54
+ (() => {
55
+ const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']);
56
+ const inBoilerplate = (el) => {
57
+ for (let n = el; n; n = n.parentElement) {
58
+ if (BOILERPLATE.has(n.tagName)) return true;
59
+ const role = n.getAttribute && n.getAttribute('role');
60
+ if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true;
61
+ }
62
+ return false;
63
+ };
64
+ const kindOf = (tag) => {
65
+ if (/^H[1-6]$/.test(tag)) return 'heading';
66
+ if (tag === 'LI') return 'listitem';
67
+ if (tag === 'BLOCKQUOTE') return 'blockquote';
68
+ if (tag === 'P') return 'paragraph';
69
+ return 'other';
70
+ };
71
+ const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote';
72
+ const root = document.querySelector('article, main, [role=main]') || document.body;
73
+ const blocks = [];
74
+ for (const el of root.querySelectorAll(selector)) {
75
+ const text = (el.innerText || '').replace(/\\s+/g, ' ').trim();
76
+ if (!text) continue;
77
+ let linkTextLength = 0;
78
+ for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length;
79
+ blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) });
80
+ }
81
+ const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT});
82
+ return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText });
83
+ })()
84
+ `;
85
+
86
+ const toPageCapture = (raw: unknown): PageCapture | undefined => {
87
+ if (typeof raw === "string") {
88
+ const parsed = parseJson(raw, pageCaptureValidator);
89
+ return parsed.success ? parsed.data : undefined;
37
90
  }
91
+ return pageCaptureValidator.Check(raw) ? raw : undefined;
38
92
  };
39
93
 
40
94
  const captureToResult = async (raw: unknown): Promise<Result<ToolOk, ToolErr>> => {
41
- const parsed = typeof raw === "string" ? safeParse(raw) : raw;
42
- if (!isPageCapture(parsed)) return err({ kind: "internal", message: "page capture returned an unexpected shape" });
95
+ const parsed = toPageCapture(raw);
96
+ if (parsed === undefined) return err({ kind: "internal", message: "page capture returned an unexpected shape" });
43
97
  const page: ReadablePage = extractReadable(parsed);
44
98
  const header = `# ${page.title}\n${page.url}\n(${page.wordCount} words)\n\n`;
45
99
  const truncated = await applyTruncation(header + page.text, "readpage");
@@ -73,7 +127,7 @@ const readOpenedUrl = async (
73
127
  if (!navigated.success) return err(navigated.error);
74
128
  const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
75
129
  if (!loaded.success) return err(loaded.error);
76
- const captured = await evalInIsolatedTab(client, tab, buildPageCaptureExpr());
130
+ const captured = await evalInIsolatedTab(client, tab, buildPageCaptureExpr(), isJsonText);
77
131
  if (!captured.success) return err(captured.error);
78
132
  return captureToResult(captured.data);
79
133
  } finally {
@@ -88,11 +142,11 @@ const readOwnedTab = async (
88
142
  if (!client.owns(targetId)) {
89
143
  return err({ kind: "invalid_state", message: `Tab ${targetId} is not owned by this session.` });
90
144
  }
91
- const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
92
- if (!attached.success) return err({ kind: "cdp_error", message: attached.error.message });
93
- const sessionId = (attached.data as { sessionId: string }).sessionId;
94
- const captured = await client.evaluateJs(buildPageCaptureExpr(), sessionId);
95
- if (!captured.success) return err({ kind: "cdp_error", message: captured.error.message });
145
+ const attached = await cdpCallBrowser(client, "Target.attachToTarget", { targetId, flatten: true });
146
+ if (!attached.success) return attached;
147
+ const sessionId = attached.data.sessionId;
148
+ const captured = await evalJs(client, buildPageCaptureExpr(), sessionId);
149
+ if (!captured.success) return captured;
96
150
  return captureToResult(captured.data);
97
151
  };
98
152
 
@@ -103,11 +157,11 @@ export const readPageTool = defineBrowserTool({
103
157
  "Read a page as clean article text — main content with nav/ads/boilerplate stripped. Pass a url (opened in an isolated tab, read, then closed) or a targetId of an owned tab. Reader-mode counterpart to browser_web_search.",
104
158
  promptSnippet: "Read a page's main content as clean text",
105
159
  promptGuidelines: [
106
- "Pass url to read an arbitrary page (opened + closed in its own tab, never disturbing your current tab), or targetId to read an already-open owned tab.",
107
160
  "Returns readable main-article text — use this over browser_snapshot/browser_execute_js when you want an article's content for reading or research.",
108
161
  "Boilerplate-heavy or structure-less pages fall back to bounded body text rather than erroring.",
109
162
  ],
110
163
  parameters: ReadPageArgs,
164
+ concurrency: "parallel",
111
165
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
112
166
  if (args.url !== undefined && args.url.length > 0) return readOpenedUrl(client, args.url, signal);
113
167
  if (args.targetId !== undefined && args.targetId.length > 0) return readOwnedTab(client, args.targetId);
@@ -1,35 +1,17 @@
1
- /**
2
- * Pure reader-mode extraction — no CDP, no DOM. The in-page script
3
- * (see capture.ts) serializes candidate content blocks; these pure functions
4
- * score them and assemble clean article text, so the selection logic is
5
- * unit-testable against saved fixtures (test/deep-research/readability-test.ts).
6
- *
7
- * Dependency-free by design (no jsdom / Readability): the harness ships no new
8
- * runtime deps. The heuristic favors blocks with high text density and low link
9
- * density, which is what separates article body from nav/ads/boilerplate.
10
- */
11
-
12
- /** One candidate content block captured in-page. */
13
1
  export type ContentBlock = {
14
- /** Semantic tag: paragraph | heading | listitem | blockquote | other. */
15
2
  readonly kind: "paragraph" | "heading" | "listitem" | "blockquote" | "other";
16
3
  readonly text: string;
17
- /** Chars of text that sit inside <a> tags — high ratio ⇒ nav/menu, not prose. */
18
4
  readonly linkTextLength: number;
19
- /** True when an ancestor is nav/header/footer/aside — structural boilerplate. */
20
5
  readonly inBoilerplate: boolean;
21
6
  };
22
7
 
23
- /** The JSON payload capture.ts returns from the page. */
24
8
  export type PageCapture = {
25
9
  readonly url: string;
26
10
  readonly title: string;
27
11
  readonly blocks: ReadonlyArray<ContentBlock>;
28
- /** Bounded innerText fallback when block extraction finds nothing usable. */
29
12
  readonly bodyText: string;
30
13
  };
31
14
 
32
- /** The tool's public output shape. */
33
15
  export type ReadablePage = {
34
16
  readonly title: string;
35
17
  readonly url: string;
@@ -46,7 +28,6 @@ const linkDensity = (block: ContentBlock): number => {
46
28
  return block.linkTextLength / block.text.length;
47
29
  };
48
30
 
49
- /** A block is article prose if it is substantive, text-dense, and not chrome. */
50
31
  const isArticleBlock = (block: ContentBlock): boolean => {
51
32
  if (block.inBoilerplate) return false;
52
33
  if (linkDensity(block) > MAX_LINK_DENSITY) return false;
@@ -69,11 +50,6 @@ const wordCountOf = (text: string): number => {
69
50
  return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
70
51
  };
71
52
 
72
- /**
73
- * Assemble readable article text from a page capture. Keeps text-dense,
74
- * non-boilerplate blocks; falls back to the bounded body text when the article
75
- * is too thin (e.g. a page with no clear content structure).
76
- */
77
53
  export const extractReadable = (capture: PageCapture): ReadablePage => {
78
54
  const articleText = capture.blocks.filter(isArticleBlock).map(render).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
79
55
 
@@ -1,9 +1,9 @@
1
1
  import type { BrowserClient } from "../client";
2
2
  import { type Result, err, ok } from "../util/result";
3
3
  import type { ToolErr } from "../util/tool";
4
- import { buildTree, collectInteractiveTargets, type RawAxNode, type SlimNode } from "./snapshot";
5
-
6
- type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
4
+ import { type Box, boxOf } from "./box";
5
+ import { cdpCall } from "./cdp-call";
6
+ import { buildTree, collectInteractiveTargets, type SlimNode } from "./ax-tree";
7
7
 
8
8
  const staleErr = (ref: string): ToolErr => ({
9
9
  kind: "invalid_state",
@@ -11,106 +11,72 @@ const staleErr = (ref: string): ToolErr => ({
11
11
  details: { ref },
12
12
  });
13
13
 
14
- /**
15
- * Resolve a ref to its CDP objectId (a handle to the live JS node). Use when a
16
- * tool needs to run JS against the element (fill, focus, select, dispatch_key).
17
- * Re-resolves through the backendNodeId at call time, so it reflects the current
18
- * DOM even after re-renders — and fails loudly if the node was detached.
19
- */
20
14
  export const resolveRefToObjectId = async (
21
15
  client: BrowserClient,
22
16
  ref: string,
23
17
  ): Promise<Result<string, ToolErr>> => {
24
18
  const backendId = client.session().resolveRef(ref);
25
19
  if (backendId === undefined) return err(staleErr(ref));
26
- const resolved = await client.session().call("DOM.resolveNode", { backendNodeId: backendId });
20
+ const resolved = await cdpCall(client, "DOM.resolveNode", { backendNodeId: backendId });
27
21
  if (!resolved.success) {
28
- // A detached node (removed by a re-render) fails to resolve — surface it as
29
- // a stale ref rather than a raw CDP error so the agent knows to re-snapshot.
30
22
  return err(staleErr(ref));
31
23
  }
32
- const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
24
+ const objectId = resolved.data.object.objectId;
33
25
  if (!objectId) return err(staleErr(ref));
34
26
  return ok(objectId);
35
27
  };
36
28
 
37
- /** Resolve a ref to its current backendNodeId (for CDP calls that take one, e.g. DOM.setFileInputFiles). */
38
29
  export const resolveRefToBackendId = (client: BrowserClient, ref: string): Result<number, ToolErr> => {
39
30
  const backendId = client.session().resolveRef(ref);
40
31
  if (backendId === undefined) return err(staleErr(ref));
41
32
  return ok(backendId);
42
33
  };
43
34
 
44
- /**
45
- * Resolve a ref to the current center of its bounding box, in viewport CSS
46
- * pixels. Use for browser_click. Fetched fresh via DOM.getBoxModel, so a
47
- * re-rendered/moved element is clicked at its new position.
48
- */
49
35
  export const resolveRefToBox = async (
50
36
  client: BrowserClient,
51
37
  ref: string,
52
38
  ): Promise<Result<Box, ToolErr>> => {
53
39
  const backendId = client.session().resolveRef(ref);
54
40
  if (backendId === undefined) return err(staleErr(ref));
55
- const r = await client.session().call("DOM.getBoxModel", { backendNodeId: backendId });
56
- if (!r.success) return err(staleErr(ref));
57
- const data = r.data as { model?: { content?: ReadonlyArray<number>; width?: number; height?: number } };
58
- const content = data.model?.content;
59
- if (!content || content.length < 8) return err(staleErr(ref));
60
- const x = content[0]!;
61
- const y = content[1]!;
62
- const width = data.model!.width ?? 0;
63
- const height = data.model!.height ?? 0;
64
- if (width <= 0 || height <= 0) return err(staleErr(ref));
65
- return ok({
66
- x: Math.round(x),
67
- y: Math.round(y),
68
- width: Math.round(width),
69
- height: Math.round(height),
70
- cx: Math.round(x + width / 2),
71
- cy: Math.round(y + height / 2),
72
- });
41
+ const box = await boxOf(client, backendId);
42
+ if (!box.success) return err(staleErr(ref));
43
+ return ok(box.data);
73
44
  };
74
45
 
75
46
  const sigOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}|${n.value ?? ""}|${n.state ?? ""}`;
76
47
  const keyOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}`;
77
48
 
78
- /**
79
- * Recompute the interactive elements after a mutation and return a compact,
80
- * human/LLM-readable diff vs. the prior snapshot's baseline — changed values,
81
- * newly-appeared elements (marked `*`), and removed ones. Re-publishes the fresh
82
- * ref map so subsequent ref-based calls target the post-mutation DOM.
83
- *
84
- * Deliberately cheap: it re-reads the AX tree (no per-node box fetch — the
85
- * expensive part of a full snapshot) and emits a few lines, not a full tree, so
86
- * a long action sequence doesn't blow up the context.
87
- *
88
- * Returns "" on any failure or when nothing changed — callers append it only if
89
- * non-empty, so a diff is best-effort and never blocks the primary action.
90
- */
91
49
  export const interactiveDiff = async (client: BrowserClient): Promise<string> => {
92
50
  const session = client.session();
93
51
  const prev = session.refSignatures();
94
52
 
95
- const axRes = await session.call("Accessibility.getFullAXTree", {});
53
+ const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {});
96
54
  if (!axRes.success) return "";
97
- const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
55
+ const rawNodes = axRes.data.nodes;
98
56
  const slim = buildTree(rawNodes, { interestingOnly: true, maxNodes: 1000 });
99
57
  const targets = collectInteractiveTargets(slim);
100
58
 
101
- // Re-publish fresh refs so later ref calls resolve against the new DOM.
59
+ const priorRefs = session.refMappings();
60
+ const refByBackendId = new Map<number, string>();
61
+ for (const [ref, backendId] of priorRefs) refByBackendId.set(backendId, ref);
62
+ let nextIndex = 1;
63
+ for (const ref of priorRefs.keys()) {
64
+ const digits = /^e(\d+)$/.exec(ref)?.[1];
65
+ if (digits !== undefined) nextIndex = Math.max(nextIndex, Number(digits) + 1);
66
+ }
102
67
  const refMap = new Map<string, number>();
103
68
  const refSig = new Map<string, string>();
104
- targets.forEach(({ node, backendId }, i) => {
105
- const ref = `e${i + 1}`;
69
+ const taken = new Set<string>();
70
+ for (const { node, backendId } of targets) {
71
+ const prior = refByBackendId.get(backendId);
72
+ const ref = prior !== undefined && !taken.has(prior) ? prior : `e${nextIndex++}`;
73
+ taken.add(ref);
106
74
  node.ref = ref;
107
75
  refMap.set(ref, backendId);
108
76
  refSig.set(ref, sigOf(node));
109
- });
77
+ }
110
78
  session.setRefMap(refMap, refSig);
111
79
 
112
- // Compare by stable identity (role|name) rather than ref position, since refs
113
- // renumber when the element set changes. Build prev/current identity → value.
114
80
  const prevByKey = new Map<string, string>();
115
81
  for (const sig of prev.values()) {
116
82
  const parts = sig.split("|");
@@ -1,44 +1,23 @@
1
- /**
2
- * Shared TUI renderer for text-heavy tool output (browser_web_search,
3
- * browser_read_page). Gives the native pi expand/collapse (Ctrl+O) behavior:
4
- * a compact preview by default, the full body when expanded — mirroring the
5
- * pattern in browser_execute_js (src/domains/js.ts:81-109).
6
- *
7
- * The full text always goes to the LLM via ToolOk.text; this only governs the
8
- * human-facing TUI view.
9
- */
10
1
  import { Markdown, Text } from "@mariozechner/pi-tui";
11
2
  import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
12
3
  import type { Component } from "@mariozechner/pi-tui";
13
4
  import type { Theme } from "@mariozechner/pi-coding-agent";
5
+ import { isRecord } from "../util/guards";
14
6
 
15
7
  const COMPACT_PREVIEW_LINES = 6;
16
8
 
17
- /** Structured payload a text-heavy tool stores so renderResult can rebuild views. */
18
- export type ExpandableText = {
19
- /** One-line summary shown above the body in both states (e.g. "5 results · google"). */
9
+ type ExpandableText = {
20
10
  readonly summary: string;
21
- /** The full rendered body (markdown/plain). */
22
11
  readonly body: string;
23
- /** Path to the on-disk full output when the LLM text was truncated, if any. */
24
12
  readonly fullOutputPath?: string;
25
13
  };
26
14
 
27
- const isExpandableText = (value: unknown): value is ExpandableText => {
28
- if (typeof value !== "object" || value === null) return false;
29
- const v = value as Record<string, unknown>;
30
- return typeof v["summary"] === "string" && typeof v["body"] === "string";
31
- };
15
+ const isExpandableText = (value: unknown): value is ExpandableText =>
16
+ isRecord(value) && typeof value["summary"] === "string" && typeof value["body"] === "string";
32
17
 
33
- const extractRender = (details: unknown): unknown => {
34
- if (typeof details !== "object" || details === null) return undefined;
35
- return (details as Record<string, unknown>)["render"];
36
- };
18
+ const extractRender = (details: unknown): unknown =>
19
+ isRecord(details) ? details["render"] : undefined;
37
20
 
38
- /**
39
- * Build a renderResult over an ExpandableText stored in `details.render`.
40
- * `label` names the tool for the error fallback (e.g. "web_search").
41
- */
42
21
  export const renderExpandableText = (
43
22
  label: string,
44
23
  result: { readonly details?: unknown },