pi-browser-harness 0.10.2 → 0.11.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 (88) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -9
  3. package/package.json +5 -2
  4. package/skills/pi-browser-harness/SKILL.md +1 -1
  5. package/src/cdp/commands.ts +256 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +28 -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 +150 -203
  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 +18 -68
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
  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 -32
  28. package/src/domains/console.ts +48 -2
  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 +22 -26
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +65 -0
  35. package/src/domains/form.ts +116 -105
  36. package/src/domains/history.ts +13 -9
  37. package/src/domains/isolated-tab.ts +26 -55
  38. package/src/domains/js.ts +29 -42
  39. package/src/domains/keyboard.ts +36 -27
  40. package/src/domains/navigate.ts +16 -23
  41. package/src/domains/network.ts +60 -10
  42. package/src/domains/page.ts +24 -3
  43. package/src/domains/readpage/read-page.ts +74 -19
  44. package/src/domains/readpage/readability.ts +0 -24
  45. package/src/domains/ref-resolve.ts +24 -58
  46. package/src/domains/render.ts +6 -27
  47. package/src/domains/screenshot-capture.ts +80 -0
  48. package/src/domains/screenshot.ts +9 -86
  49. package/src/domains/scroll.ts +10 -11
  50. package/src/domains/search/google-serp.ts +0 -29
  51. package/src/domains/search/web-search.ts +48 -20
  52. package/src/domains/setup.ts +1 -11
  53. package/src/domains/snapshot.ts +35 -276
  54. package/src/domains/tabs.ts +14 -7
  55. package/src/domains/target.ts +35 -0
  56. package/src/domains/viewport.ts +5 -3
  57. package/src/index.ts +8 -58
  58. package/src/profile/bind.ts +8 -57
  59. package/src/profile/browser-process.ts +75 -126
  60. package/src/profile/command.ts +50 -29
  61. package/src/profile/launch.ts +22 -40
  62. package/src/profile/list.ts +14 -54
  63. package/src/profile/paths.ts +2 -65
  64. package/src/profile/store.ts +35 -56
  65. package/src/registry.ts +30 -33
  66. package/src/schemas/parse.ts +23 -0
  67. package/src/setup.ts +0 -25
  68. package/src/state.ts +16 -16
  69. package/src/util/debug.ts +8 -0
  70. package/src/util/guards.ts +30 -0
  71. package/src/util/js-template.ts +1 -16
  72. package/src/util/mutex.ts +0 -5
  73. package/src/util/result.ts +0 -6
  74. package/src/util/sharp-shim.ts +12 -19
  75. package/src/util/tool.ts +17 -42
  76. package/src/util/truncate.ts +6 -0
  77. package/tsconfig.json +1 -16
  78. package/skills/deep-research/SKILL.md +0 -66
  79. package/src/cdp/event-queue.ts +0 -128
  80. package/src/cdp/transport.ts +0 -156
  81. package/src/cdp/window-binding.ts +0 -15
  82. package/src/deep-research.ts +0 -41
  83. package/src/domains/forms.ts +0 -355
  84. package/src/domains/readpage/capture.ts +0 -46
  85. package/src/domains/search/extract.ts +0 -44
  86. package/src/profile/picker.ts +0 -73
  87. package/src/util/keycodes.ts +0 -10
  88. package/src/util/time.ts +0 -29
@@ -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
 
@@ -108,6 +162,7 @@ export const readPageTool = defineBrowserTool({
108
162
  "Boilerplate-heavy or structure-less pages fall back to bounded body text rather than erroring.",
109
163
  ],
110
164
  parameters: ReadPageArgs,
165
+ concurrency: "parallel",
111
166
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
112
167
  if (args.url !== undefined && args.url.length > 0) return readOpenedUrl(client, args.url, signal);
113
168
  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 },
@@ -0,0 +1,80 @@
1
+ import { rename, writeFile } from "node:fs/promises";
2
+ import type { BrowserClient } from "../client";
3
+ import { type Result, ok } from "../util/result";
4
+ import type { ToolErr } from "../util/tool";
5
+ import { screenshotPath } from "../util/paths";
6
+ import { loadSharp } from "../util/sharp-shim";
7
+ import { safeJs } from "../util/js-template";
8
+ import { cdpCall, evalJs } from "./cdp-call";
9
+
10
+ export type CaptureArgs = {
11
+ readonly fullPage?: boolean;
12
+ readonly format?: "png" | "jpeg";
13
+ readonly quality?: number;
14
+ };
15
+
16
+ export const captureBase = async (
17
+ client: BrowserClient,
18
+ args: CaptureArgs,
19
+ ): Promise<Result<{ readonly path: string; readonly format: "png" | "jpeg" }, ToolErr>> => {
20
+ const format = args.format ?? "png";
21
+ const quality = args.quality ?? 80;
22
+ const path = screenshotPath(client.namespace, format);
23
+ const r = await cdpCall(client, "Page.captureScreenshot", {
24
+ format,
25
+ captureBeyondViewport: args.fullPage ?? false,
26
+ ...(format === "jpeg" ? { quality } : {}),
27
+ });
28
+ if (!r.success) return r;
29
+ await writeFile(path, Buffer.from(r.data.data, "base64"));
30
+ return ok({ path, format });
31
+ };
32
+
33
+ export const resizeIfNeeded = async (
34
+ path: string,
35
+ maxDim: number,
36
+ ): Promise<{ readonly note: string }> => {
37
+ const load = await loadSharp();
38
+ if (load.kind === "missing") return { note: " (maxDim ignored: install sharp for auto-resize)" };
39
+ if (load.kind === "error") return { note: ` (maxDim ignored: sharp failed to load: ${load.message})` };
40
+ try {
41
+ const meta = await load.sharp(path).metadata();
42
+ const w = meta.width ?? 0;
43
+ const h = meta.height ?? 0;
44
+ if (Math.max(w, h) <= maxDim) return { note: "" };
45
+ const tmp = `${path}.resized`;
46
+ await load.sharp(path).resize(maxDim, maxDim, { fit: "inside" }).toFile(tmp);
47
+ await rename(tmp, path);
48
+ return { note: ` (resized to fit ${maxDim}px)` };
49
+ } catch (e) {
50
+ return { note: ` (maxDim ignored: sharp threw: ${e instanceof Error ? e.message : String(e)})` };
51
+ }
52
+ };
53
+
54
+ export const captureWithCrosshair = async (
55
+ client: BrowserClient,
56
+ args: { readonly x: number; readonly y: number; readonly format?: "png" | "jpeg"; readonly quality?: number },
57
+ ): Promise<Result<{ readonly path: string }, ToolErr>> => {
58
+ const cap = await captureBase(client, args);
59
+ if (!cap.success) return cap;
60
+ const load = await loadSharp();
61
+ if (load.kind !== "ok") return ok({ path: cap.data.path });
62
+ try {
63
+ const dprR = await evalJs(client, safeJs`window.devicePixelRatio`);
64
+ const dpr = dprR.success && typeof dprR.data === "number" ? dprR.data : 1;
65
+ const meta = await load.sharp(cap.data.path).metadata();
66
+ const w = meta.width ?? 0;
67
+ const h = meta.height ?? 0;
68
+ const px = Math.round(args.x * dpr);
69
+ const py = Math.round(args.y * dpr);
70
+ const r = Math.round(15 * dpr);
71
+ const stroke = Math.max(2, Math.round(3 * dpr));
72
+ const svg = `<svg width="${w}" height="${h}"><circle cx="${px}" cy="${py}" r="${r}" fill="none" stroke="red" stroke-width="${stroke}" opacity="0.8"/><line x1="${px - r - 5}" y1="${py}" x2="${px + r + 5}" y2="${py}" stroke="red" stroke-width="${Math.max(1, stroke - 1)}" opacity="0.8"/><line x1="${px}" y1="${py - r - 5}" x2="${px}" y2="${py + r + 5}" stroke="red" stroke-width="${Math.max(1, stroke - 1)}" opacity="0.8"/></svg>`;
73
+ const tmp = `${cap.data.path}.debug`;
74
+ await load.sharp(cap.data.path).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).toFile(tmp);
75
+ await rename(tmp, cap.data.path);
76
+ return ok({ path: cap.data.path });
77
+ } catch {
78
+ return ok({ path: cap.data.path });
79
+ }
80
+ };
@@ -1,13 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { writeFile, rename } from "node:fs/promises";
3
2
  import { Type } from "typebox";
4
3
  import { Image, type ImageTheme, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
5
- import type { BrowserClient } from "../client";
6
- import { type Result, err, ok } from "../util/result";
4
+ import { type Result, ok } from "../util/result";
7
5
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
8
- import { screenshotPath } from "../util/paths";
9
- import { loadSharp } from "../util/sharp-shim";
10
- import { safeJs } from "../util/js-template";
6
+ import { asRecord, asString } from "../util/guards";
7
+ import { captureBase, resizeIfNeeded } from "./screenshot-capture";
11
8
 
12
9
  const ScreenshotArgs = Type.Object({
13
10
  fullPage: Type.Optional(Type.Boolean({ default: false, description: "Capture beyond viewport" })),
@@ -16,50 +13,6 @@ const ScreenshotArgs = Type.Object({
16
13
  maxDim: Type.Optional(Type.Integer({ minimum: 100, maximum: 8000, description: "If max(w,h) exceeds this, resize via sharp." })),
17
14
  });
18
15
 
19
- type CaptureArgs = {
20
- readonly fullPage?: boolean;
21
- readonly format?: "png" | "jpeg";
22
- readonly quality?: number;
23
- };
24
-
25
- const captureBase = async (
26
- client: BrowserClient,
27
- args: CaptureArgs,
28
- ): Promise<Result<{ readonly path: string; readonly format: "png" | "jpeg" }, ToolErr>> => {
29
- const format = args.format ?? "png";
30
- const quality = args.quality ?? 80;
31
- const path = screenshotPath(client.namespace, format);
32
- const params: Record<string, unknown> = { format, captureBeyondViewport: args.fullPage ?? false };
33
- if (format === "jpeg") params["quality"] = quality;
34
- const r = await client.session().call("Page.captureScreenshot", params);
35
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
36
- // Page.captureScreenshot returns { data: base64 }
37
- const data = (r.data as { data: string }).data;
38
- await writeFile(path, Buffer.from(data, "base64"));
39
- return ok({ path, format });
40
- };
41
-
42
- const resizeIfNeeded = async (
43
- path: string,
44
- maxDim: number,
45
- ): Promise<{ readonly note: string }> => {
46
- const load = await loadSharp();
47
- if (load.kind === "missing") return { note: " (maxDim ignored: install sharp for auto-resize)" };
48
- if (load.kind === "error") return { note: ` (maxDim ignored: sharp failed to load: ${load.message})` };
49
- try {
50
- const meta = await load.sharp(path).metadata();
51
- const w = meta.width ?? 0;
52
- const h = meta.height ?? 0;
53
- if (Math.max(w, h) <= maxDim) return { note: "" };
54
- const tmp = `${path}.resized`;
55
- await load.sharp(path).resize(maxDim, maxDim, { fit: "inside" }).toFile(tmp);
56
- await rename(tmp, path);
57
- return { note: ` (resized to fit ${maxDim}px)` };
58
- } catch (e) {
59
- return { note: ` (maxDim ignored: sharp threw: ${e instanceof Error ? e.message : String(e)})` };
60
- }
61
- };
62
-
63
16
  export const screenshotTool = defineBrowserTool({
64
17
  name: "browser_screenshot",
65
18
  label: "Browser Screenshot",
@@ -74,6 +27,7 @@ export const screenshotTool = defineBrowserTool({
74
27
  "format='jpeg' with quality 60-90 keeps screenshots small for photo-heavy pages. Use maxDim if the page is huge.",
75
28
  ],
76
29
  parameters: ScreenshotArgs,
30
+ concurrency: "parallel",
77
31
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
78
32
  const cap = await captureBase(client, args);
79
33
  if (!cap.success) return cap;
@@ -89,15 +43,16 @@ export const screenshotTool = defineBrowserTool({
89
43
  },
90
44
 
91
45
  renderResult(result, _expanded, theme) {
92
- const details = result.details as { path?: string; format?: string } | undefined;
93
- const filePath = details?.path;
46
+ const raw = asRecord(result.details);
47
+ const filePath = raw === undefined ? undefined : asString(raw["path"]);
94
48
  if (!filePath) {
95
49
  return new Text(theme.fg("error", "Screenshot path missing"), 0, 0);
96
50
  }
51
+ const format = raw === undefined ? undefined : asString(raw["format"]);
97
52
  try {
98
53
  const buf = readFileSync(filePath);
99
54
  const b64 = buf.toString("base64");
100
- const mimeType = details?.format === "jpeg" ? "image/jpeg" : "image/png";
55
+ const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
101
56
  const imageTheme: ImageTheme = {
102
57
  fallbackColor: (str: string) => theme.fg("dim", str),
103
58
  };
@@ -106,10 +61,7 @@ export const screenshotTool = defineBrowserTool({
106
61
  maxHeightCells: 24,
107
62
  filename: filePath,
108
63
  });
109
- // Image's text fallback (when terminal lacks inline-image support) does
110
- // not respect the rendered width, so a long file path can blow past the
111
- // terminal width and crash the host TUI. Wrap the component to truncate
112
- // each rendered line to fit.
64
+ // Image's text fallback does not respect the rendered width, so an unwrapped long path can blow past the terminal width and crash the host TUI.
113
65
  return {
114
66
  invalidate: () => image.invalidate(),
115
67
  render: (width: number) =>
@@ -122,32 +74,3 @@ export const screenshotTool = defineBrowserTool({
122
74
  }
123
75
  },
124
76
  });
125
-
126
- export const captureWithCrosshair = async (
127
- client: BrowserClient,
128
- args: { readonly x: number; readonly y: number; readonly format?: "png" | "jpeg"; readonly quality?: number },
129
- ): Promise<Result<{ readonly path: string }, ToolErr>> => {
130
- const cap = await captureBase(client, args);
131
- if (!cap.success) return cap;
132
- const load = await loadSharp();
133
- if (load.kind !== "ok") return ok({ path: cap.data.path });
134
- try {
135
- const dprR = await client.evaluateJs(safeJs`window.devicePixelRatio`);
136
- const dpr = dprR.success && typeof dprR.data === "number" ? dprR.data : 1;
137
- const meta = await load.sharp(cap.data.path).metadata();
138
- const w = meta.width ?? 0;
139
- const h = meta.height ?? 0;
140
- const px = Math.round(args.x * dpr);
141
- const py = Math.round(args.y * dpr);
142
- const r = Math.round(15 * dpr);
143
- const stroke = Math.max(2, Math.round(3 * dpr));
144
- const svg = `<svg width="${w}" height="${h}"><circle cx="${px}" cy="${py}" r="${r}" fill="none" stroke="red" stroke-width="${stroke}" opacity="0.8"/><line x1="${px - r - 5}" y1="${py}" x2="${px + r + 5}" y2="${py}" stroke="red" stroke-width="${Math.max(1, stroke - 1)}" opacity="0.8"/><line x1="${px}" y1="${py - r - 5}" x2="${px}" y2="${py + r + 5}" stroke="red" stroke-width="${Math.max(1, stroke - 1)}" opacity="0.8"/></svg>`;
145
- const tmp = `${cap.data.path}.debug`;
146
- await load.sharp(cap.data.path).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).toFile(tmp);
147
- await rename(tmp, cap.data.path);
148
- return ok({ path: cap.data.path });
149
- } catch {
150
- // Crosshair overlay is best-effort; on failure return the plain screenshot.
151
- return ok({ path: cap.data.path });
152
- }
153
- };
@@ -1,6 +1,7 @@
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 { cdpCall } from "./cdp-call";
4
5
 
5
6
  const ScrollArgs = Type.Object({
6
7
  x: Type.Optional(Type.Number({ description: "X coordinate where to scroll. Default: viewport center" })),
@@ -23,6 +24,7 @@ export const scrollTool = defineBrowserTool({
23
24
  "Pass x/y to target a specific scrollable region (e.g., a div with overflow); otherwise scrolls the page at viewport center.",
24
25
  ],
25
26
  parameters: ScrollArgs,
27
+ concurrency: "serialized",
26
28
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
27
29
  const info = await client.pageInfo();
28
30
  if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
@@ -36,22 +38,19 @@ export const scrollTool = defineBrowserTool({
36
38
  const cy = args.y ?? Math.round(info.data.height / 2);
37
39
  const dx = args.deltaX ?? 0;
38
40
  const dy = args.deltaY ?? 300;
39
- // The page target must be focused or Input events are dropped silently
40
- // (mouseWheel hangs; synthesizeScrollGesture returns ok but no scroll).
41
- // Page.bringToFront makes the target the active one in the browser.
42
- const front = await client.session().call("Page.bringToFront", {});
43
- if (!front.success) return err({ kind: "cdp_error", message: front.error.message });
44
- // Establish compositor mouse position before mouseWheel; without this,
45
- // CDP sometimes drops the wheel event with a timeout.
46
- const moved = await client.session().call("Input.dispatchMouseEvent", {
41
+ // The page target must be focused or Input events are dropped silently: mouseWheel hangs and synthesizeScrollGesture returns ok without scrolling.
42
+ const front = await cdpCall(client, "Page.bringToFront", {});
43
+ if (!front.success) return front;
44
+ // Establish compositor mouse position first, or CDP sometimes drops the wheel event with a timeout.
45
+ const moved = await cdpCall(client, "Input.dispatchMouseEvent", {
47
46
  type: "mouseMoved", x: cx, y: cy, button: "none", buttons: 0,
48
47
  });
49
- if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message });
50
- const wheel = await client.session().call("Input.dispatchMouseEvent", {
48
+ if (!moved.success) return moved;
49
+ const wheel = await cdpCall(client, "Input.dispatchMouseEvent", {
51
50
  type: "mouseWheel", x: cx, y: cy, deltaX: dx, deltaY: dy,
52
51
  button: "none", buttons: 0, pointerType: "mouse",
53
52
  });
54
- if (!wheel.success) return err({ kind: "cdp_error", message: wheel.error.message });
53
+ if (!wheel.success) return wheel;
55
54
  return ok({
56
55
  text: `Scrolled at (${cx}, ${cy}) by (${dx}, ${dy})`,
57
56
  details: { x: cx, y: cy, deltaX: dx, deltaY: dy },