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
package/src/domains/js.ts CHANGED
@@ -6,28 +6,18 @@ import { Type } from "typebox";
6
6
  import { Markdown, Text } from "@mariozechner/pi-tui";
7
7
  import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
8
8
  import { type Result, err, ok } from "../util/result";
9
+ import { formatBytes } from "../util/truncate";
9
10
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
11
+ import { evalJs } from "./cdp-call";
10
12
  import { applyTruncation } from "../util/truncate";
13
+ import { asNumber, asRecord, asString, isRecord } from "../util/guards";
11
14
 
12
15
  const COMPACT_PREVIEW_BYTES = 120;
13
16
 
14
- const formatBytes = (n: number): string => {
15
- if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
16
- if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
17
- return `${n} B`;
18
- };
19
-
20
- // AsyncFunction constructor — the documented Node.js mechanism for compiling
21
- // arbitrary user-supplied source. Equivalent to `new Function` but produces an
22
- // async function we can `await`. The full RCE risk is contained at the source
23
- // boundary: see the path allowlist + timeout below. A plain `unknown` cast
24
- // would lose the constructor signature; we keep this single typed cast.
17
+ // The AsyncFunction constructor is the documented way to compile user source; a plain `unknown` cast would lose the constructor signature, so this typed cast stays.
25
18
  const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as
26
19
  new (...args: ReadonlyArray<string>) => (...args: ReadonlyArray<unknown>) => Promise<unknown>;
27
20
 
28
- // ESNext module — bare `require` is not in scope. createRequire provides the
29
- // CJS resolver anchored to this file's URL so scripts can require() builtins
30
- // and installed CJS packages.
31
21
  const requireFromHere = createRequire(import.meta.url);
32
22
 
33
23
  const ExecuteJsArgs = Type.Object({
@@ -51,16 +41,15 @@ export const executeJsTool = defineBrowserTool({
51
41
  "Result must be JSON-serializable (Runtime.evaluate returnByValue=true).",
52
42
  ],
53
43
  parameters: ExecuteJsArgs,
44
+ concurrency: "parallel",
54
45
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
55
- const r = await client.evaluateJs(args.expression, args.targetId);
56
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
46
+ const r = await evalJs(client, args.expression, args.targetId);
47
+ if (!r.success) return r;
57
48
  const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data);
58
49
  const truncated = await applyTruncation(valueStr, "js");
59
- // If the value is JSON, pretty-print for the expanded TUI view. Falls back
60
- // to the raw string if parse fails (e.g. value is "undefined" sentinel).
61
50
  let pretty: string | undefined;
62
51
  try {
63
- const parsed = JSON.parse(valueStr);
52
+ const parsed: unknown = JSON.parse(valueStr);
64
53
  pretty = JSON.stringify(parsed, null, 2);
65
54
  } catch {
66
55
  pretty = undefined;
@@ -69,8 +58,6 @@ export const executeJsTool = defineBrowserTool({
69
58
  text: truncated.text,
70
59
  details: {
71
60
  valueLength: valueStr.length,
72
- // Full value for the expanded renderer. Capped at 200 KB so a giant
73
- // dump doesn't bloat the conversation transcript on disk.
74
61
  full: valueStr.length > 200_000 ? valueStr.slice(0, 200_000) : valueStr,
75
62
  ...(pretty !== undefined ? { pretty: pretty.length > 200_000 ? pretty.slice(0, 200_000) : pretty } : {}),
76
63
  ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
@@ -79,10 +66,14 @@ export const executeJsTool = defineBrowserTool({
79
66
  },
80
67
 
81
68
  renderResult(result, expanded, theme) {
82
- const details = result.details as
83
- | { valueLength?: number; full?: string; pretty?: string; fullOutputPath?: string }
84
- | undefined;
85
- if (!details) return new Text(theme.fg("error", "execute_js: no details"), 0, 0);
69
+ const raw = asRecord(result.details);
70
+ if (raw === undefined) return new Text(theme.fg("error", "execute_js: no details"), 0, 0);
71
+ const details = {
72
+ valueLength: asNumber(raw["valueLength"]),
73
+ full: asString(raw["full"]),
74
+ pretty: asString(raw["pretty"]),
75
+ fullOutputPath: asString(raw["fullOutputPath"]),
76
+ };
86
77
 
87
78
  const len = details.valueLength ?? 0;
88
79
  const full = details.full ?? "";
@@ -140,9 +131,7 @@ const MAX_SOURCE_BYTES = 1_000_000;
140
131
  type ContentItem = { readonly type: "text"; readonly text: string };
141
132
 
142
133
  const isContentItem = (v: unknown): v is ContentItem =>
143
- typeof v === "object" && v !== null
144
- && (v as Readonly<Record<string, unknown>>)["type"] === "text"
145
- && typeof (v as Readonly<Record<string, unknown>>)["text"] === "string";
134
+ isRecord(v) && v["type"] === "text" && typeof v["text"] === "string";
146
135
 
147
136
  export const runScriptTool = defineBrowserTool({
148
137
  name: "browser_run_script",
@@ -158,6 +147,7 @@ export const runScriptTool = defineBrowserTool({
158
147
  "Script MUST return { content: [{ type: 'text', text: '...' }], details?: {...} }. Throw on errors.",
159
148
  ],
160
149
  parameters: RunScriptArgs,
150
+ concurrency: "parallel",
161
151
  async handler(args, { client, signal, onUpdate, extensionCtx }): Promise<Result<ToolOk, ToolErr>> {
162
152
  if (!isAbsolute(args.path)) {
163
153
  return err({ kind: "invalid_state", message: "Script path must be absolute" });
@@ -205,18 +195,18 @@ export const runScriptTool = defineBrowserTool({
205
195
  try {
206
196
  const scriptPromise = executeFn(
207
197
  args.params ?? {},
208
- client, // bound as `daemon` — name preserved for back-compat
198
+ client,
209
199
  requireFromHere,
210
200
  ac.signal,
211
201
  (u: unknown) => {
212
- if (typeof u !== "object" || u === null) return;
213
- const content = (u as Readonly<Record<string, unknown>>)["content"];
202
+ if (!isRecord(u)) return;
203
+ const content = u["content"];
214
204
  if (!Array.isArray(content) || content.length === 0) return;
215
- const first = content[0];
216
- if (typeof first !== "object" || first === null) return;
217
- const txt = (first as Readonly<Record<string, unknown>>)["text"];
205
+ const first: unknown = content[0];
206
+ if (!isRecord(first)) return;
207
+ const txt = first["text"];
218
208
  if (typeof txt !== "string") return;
219
- try { onUpdate({ text: txt }); } catch { /* swallow */ }
209
+ try { onUpdate({ text: txt }); } catch {}
220
210
  },
221
211
  extensionCtx ?? { cwd: process.cwd() },
222
212
  console, fetch, JSON, Buffer, setTimeout, clearTimeout,
@@ -228,10 +218,10 @@ export const runScriptTool = defineBrowserTool({
228
218
  clearTimeout(timeoutTimer);
229
219
  if (signal !== undefined) signal.removeEventListener("abort", onAbort);
230
220
 
231
- if (typeof result !== "object" || result === null) {
221
+ if (!isRecord(result)) {
232
222
  return err({ kind: "invalid_state", message: `Script must return an object; got ${JSON.stringify(result)}` });
233
223
  }
234
- const content = (result as Readonly<Record<string, unknown>>)["content"];
224
+ const content = result["content"];
235
225
  if (!Array.isArray(content)) {
236
226
  return err({ kind: "invalid_state", message: "Script return value must have a content array" });
237
227
  }
@@ -239,11 +229,8 @@ export const runScriptTool = defineBrowserTool({
239
229
  return err({ kind: "invalid_state", message: "Script content array must contain { type: 'text', text: string } items" });
240
230
  }
241
231
  const textOut = content.map((c) => c.text).join("\n");
242
- const details = (result as Readonly<Record<string, unknown>>)["details"];
243
- const isPlainObject = typeof details === "object" && details !== null && !Array.isArray(details);
244
- return isPlainObject
245
- ? ok({ text: textOut, details: details as Readonly<Record<string, unknown>> })
246
- : ok({ text: textOut });
232
+ const details = result["details"];
233
+ return isRecord(details) ? ok({ text: textOut, details }) : ok({ text: textOut });
247
234
  } catch (e) {
248
235
  clearTimeout(timeoutTimer);
249
236
  if (signal !== undefined) signal.removeEventListener("abort", onAbort);
@@ -1,14 +1,30 @@
1
1
  import { Type } from "typebox";
2
2
  import { safeJs } from "../util/js-template";
3
- import { virtualKeyCode } from "../util/keycodes";
4
3
  import { type Result, err, ok } from "../util/result";
5
4
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
+ import { cdpCall, evalJs } from "./cdp-call";
6
6
  import { resolveRefToObjectId } from "./ref-resolve";
7
7
 
8
+ const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
9
+ Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32,
10
+ ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
11
+ Home: 36, End: 35, PageUp: 33, PageDown: 34,
12
+ };
13
+
14
+ const virtualKeyCode = (key: string): number =>
15
+ VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
16
+
8
17
  const TypeArgs = Type.Object({
9
18
  text: Type.String({ description: "Text to type" }),
10
19
  });
11
20
 
21
+ type FocusProbe = { readonly ok: boolean; readonly tag: string | null };
22
+
23
+ const isFocusProbe = (v: unknown): v is FocusProbe =>
24
+ typeof v === "object" && v !== null
25
+ && "ok" in v && typeof v.ok === "boolean"
26
+ && "tag" in v && (typeof v.tag === "string" || v.tag === null);
27
+
12
28
  export const typeTool = defineBrowserTool({
13
29
  name: "browser_type",
14
30
  label: "Browser Type",
@@ -19,12 +35,9 @@ export const typeTool = defineBrowserTool({
19
35
  "For special keys (Enter, Tab, Escape, arrows), use browser_press_key instead.",
20
36
  ],
21
37
  parameters: TypeArgs,
38
+ concurrency: "serialized",
22
39
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
23
- // Guard against silent text loss: Input.insertText succeeds regardless of
24
- // whether an editable element is focused, so without this check typed text
25
- // can vanish into the void with no error. Fail loudly with an actionable
26
- // message instead. IIFE starts with "(" so evaluateJs returns it directly.
27
- const focused = await client.evaluateJs(safeJs`
40
+ const focused = await evalJs(client, safeJs`
28
41
  (() => {
29
42
  const el = document.activeElement;
30
43
  const ok = !!el && el !== document.body &&
@@ -33,7 +46,7 @@ export const typeTool = defineBrowserTool({
33
46
  })()
34
47
  `);
35
48
  if (focused.success) {
36
- const f = focused.data as { ok: boolean; tag: string | null };
49
+ const f: FocusProbe = isFocusProbe(focused.data) ? focused.data : { ok: false, tag: null };
37
50
  if (!f.ok) {
38
51
  return err({
39
52
  kind: "invalid_state",
@@ -43,8 +56,8 @@ export const typeTool = defineBrowserTool({
43
56
  });
44
57
  }
45
58
  }
46
- const r = await client.session().call("Input.insertText", { text: args.text });
47
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
59
+ const r = await cdpCall(client, "Input.insertText", { text: args.text });
60
+ if (!r.success) return r;
48
61
  return ok({ text: `Typed: "${args.text}"` });
49
62
  },
50
63
  });
@@ -76,6 +89,7 @@ export const pressKeyTool = defineBrowserTool({
76
89
  "Modifiers: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift. Combine with bitwise OR: Ctrl+Shift = 2|8 = 10.",
77
90
  ],
78
91
  parameters: PressKeyArgs,
92
+ concurrency: "serialized",
79
93
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
80
94
  const k = args.key;
81
95
  const code = virtualKeyCode(k);
@@ -89,18 +103,16 @@ export const pressKeyTool = defineBrowserTool({
89
103
  ...(code ? { windowsVirtualKeyCode: code, nativeVirtualKeyCode: code } : {}),
90
104
  ...(isChar ? { text: k, unmodifiedText: k } : {}),
91
105
  };
92
- const down = await client.session().call("Input.dispatchKeyEvent", downParams);
93
- if (!down.success) return err({ kind: "cdp_error", message: down.error.message });
94
- // For a printable character with no command modifier (Ctrl/Meta/Alt = 1|2|4),
95
- // emit a `char` event so the page receives keypress/textInput and the
96
- // character is actually inserted. Shift (8) is allowed (capitals/symbols).
106
+ const down = await cdpCall(client, "Input.dispatchKeyEvent", downParams);
107
+ if (!down.success) return down;
108
+ // A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable.
97
109
  const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
98
110
  if (isChar && !hasCommandModifier) {
99
- const charEv = await client.session().call("Input.dispatchKeyEvent", { type: "char", text: k, key: k });
100
- if (!charEv.success) return err({ kind: "cdp_error", message: charEv.error.message });
111
+ const charEv = await cdpCall(client, "Input.dispatchKeyEvent", { type: "char", text: k, key: k });
112
+ if (!charEv.success) return charEv;
101
113
  }
102
- const up = await client.session().call("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, modifiers });
103
- if (!up.success) return err({ kind: "cdp_error", message: up.error.message });
114
+ const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, modifiers });
115
+ if (!up.success) return up;
104
116
  return ok({ text: `Pressed: ${k}${modifiers ? ` (modifiers=${modifiers})` : ""}` });
105
117
  },
106
118
  });
@@ -133,25 +145,22 @@ export const dispatchKeyTool = defineBrowserTool({
133
145
  "eventType defaults to 'keydown'.",
134
146
  ],
135
147
  parameters: DispatchKeyArgs,
148
+ concurrency: "serialized",
136
149
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
137
150
  const eventType = args.eventType ?? "keydown";
138
151
  const target = args.ref ?? args.selector ?? "";
139
- // Legacy handlers commonly branch on e.keyCode/e.which (e.g. === 13 for
140
- // Enter) rather than e.key, so populate both. virtualKeyCode maps named
141
- // keys and single chars; 0 for anything unknown.
152
+ // Legacy handlers branch on e.keyCode/e.which rather than e.key, so populate both.
142
153
  const code = virtualKeyCode(args.key);
143
- // Ref path: dispatch on the single resolved node. Selector path: dispatch on
144
- // all matches (preserves the original multi-match behavior).
145
154
  if (args.ref !== undefined) {
146
155
  const objectId = await resolveRefToObjectId(client, args.ref);
147
156
  if (!objectId.success) return objectId;
148
- const r = await client.session().call("Runtime.callFunctionOn", {
157
+ const r = await cdpCall(client, "Runtime.callFunctionOn", {
149
158
  objectId: objectId.data,
150
159
  functionDeclaration: `function (type, key, keyCode) { this.dispatchEvent(new KeyboardEvent(type, { key, keyCode, which: keyCode, bubbles: true, cancelable: true })); return 1; }`,
151
160
  arguments: [{ value: eventType }, { value: args.key }, { value: code }],
152
161
  returnByValue: true,
153
162
  });
154
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
163
+ if (!r.success) return r;
155
164
  return ok({
156
165
  text: `Dispatched ${eventType}('${args.key}') on ${target}`,
157
166
  details: { matched: 1, ref: args.ref },
@@ -170,8 +179,8 @@ export const dispatchKeyTool = defineBrowserTool({
170
179
  return els.length;
171
180
  })()
172
181
  `;
173
- const r = await client.evaluateJs(expr);
174
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
182
+ const r = await evalJs(client, expr);
183
+ if (!r.success) return r;
175
184
  const matched = Number(r.data ?? 0);
176
185
  if (matched === 0) {
177
186
  return err({
@@ -5,12 +5,13 @@ 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
7
  import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
8
+ import { cdpCall, cdpCallBrowser, cdpCallOnTarget, evalJs } from "./cdp-call";
8
9
 
9
10
  const NavigateArgs = Type.Object({
10
11
  url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
11
12
  });
12
13
 
13
- export type NavOutcome =
14
+ type NavOutcome =
14
15
  | { readonly kind: "in_place"; readonly targetId: string }
15
16
  | { readonly kind: "new_tab_created"; readonly targetId: string; readonly reason: "no_tabs" };
16
17
 
@@ -27,6 +28,7 @@ export const navigateTool = defineBrowserTool({
27
28
  "Note: Google and some sites with strict anti-bot detection may reject CDP navigation. Use browser_http_get for search results.",
28
29
  ],
29
30
  parameters: NavigateArgs,
31
+ concurrency: "serialized",
30
32
  renderCall: (a) => new Text(`🌐 Navigate to ${a.url}`, 0, 0),
31
33
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
32
34
  const tabs = await client.listTabs(false);
@@ -37,8 +39,8 @@ export const navigateTool = defineBrowserTool({
37
39
  if (!created.success) return err({ kind: "cdp_error", message: created.error.message });
38
40
  outcome = { kind: "new_tab_created", targetId: created.data, reason: "no_tabs" };
39
41
  } else {
40
- const nav = await client.session().call("Page.navigate", { url: args.url });
41
- if (!nav.success) return err({ kind: "cdp_error", message: nav.error.message });
42
+ const nav = await cdpCall(client, "Page.navigate", { url: args.url });
43
+ if (!nav.success) return nav;
42
44
  const cur = client.current();
43
45
  if (!cur) return err({ kind: "internal", message: "navigate succeeded but no current target tracked" });
44
46
  outcome = { kind: "in_place", targetId: cur.targetId };
@@ -81,56 +83,48 @@ export const openUrlsTool = defineBrowserTool({
81
83
  "Use browser_wait_for_load on a tab before extracting data from SPAs.",
82
84
  ],
83
85
  parameters: OpenUrlsArgs,
86
+ concurrency: "serialized",
84
87
  renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
85
88
  async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
86
89
  const total = args.urls.length;
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.
90
+ // All tabs must land in ONE window: a parallel newWindow:true per url would scatter them across N windows, so the first url creates the window and the rest open as its children.
92
91
  const url0 = args.urls[0];
93
92
  if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" });
94
93
  const window = await ensureHarnessWindow(client);
95
94
  if (!window.success) return err({ kind: "cdp_error", message: window.error.message });
96
95
  const seededWindow = window.data.targetId;
97
- // A freshly created window's seed tab serves as the first URL's tab.
98
96
  const seedResult: TabResult | undefined = window.data.freshlyCreated
99
97
  ? { url: url0, targetId: seededWindow, ok: true }
100
98
  : undefined;
101
99
  const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => {
102
- // Reuse the seed tab (created above) for the first url instead of duplicating it.
103
100
  if (i === 0 && seedResult) return seedResult;
104
101
  const r = await openHarnessTab(client, seededWindow);
105
102
  if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
106
103
  return { url, targetId: r.data, ok: true };
107
104
  }));
108
- // Phase 2: attach + navigate each created tab in parallel.
109
105
  let completed = 0;
110
106
  const settled = await Promise.all(
111
107
  created.filter((t) => t.ok).map(async (tab): Promise<TabResult> => {
112
108
  try {
113
- const attached = await client.session().callBrowser("Target.attachToTarget", { targetId: tab.targetId, flatten: true });
109
+ const attached = await client.session().attach(tab.targetId);
114
110
  if (!attached.success) return { ...tab, ok: false, error: attached.error.message };
115
- const sid = (attached.data as { sessionId: string }).sessionId; // CDP boundary cast: Target.attachToTarget returns { sessionId: string }
116
- const enabled = await client.session().callOnTarget("Page.enable", {}, sid);
111
+ const sid = attached.data;
112
+ const enabled = await cdpCallOnTarget(client, "Page.enable", {}, sid);
117
113
  if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message };
118
- const nav = await client.session().callOnTarget("Page.navigate", { url: tab.url }, sid);
114
+ const nav = await cdpCallOnTarget(client, "Page.navigate", { url: tab.url }, sid);
119
115
  if (!nav.success) return { ...tab, ok: false, error: nav.error.message };
120
- // Mark the tab with a green circle so the user can see which
121
- // tabs the agent opened. Best-effort; don't fail on CSP blocks.
122
- client.evaluateJs(
116
+ evalJs(
117
+ client,
123
118
  safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
124
119
  sid
125
120
  ).catch(() => {});
126
121
  return tab;
127
122
  } finally {
128
123
  completed++;
129
- // onUpdate must never escape: a renderer crash would otherwise
130
- // tear down the tool call.
124
+ // onUpdate must never escape: a renderer crash would otherwise tear down the tool call.
131
125
  try {
132
126
  onUpdate({ text: `Opening URLs… ${completed}/${created.filter((t) => t.ok).length} navigated` });
133
- } catch { /* swallow */ }
127
+ } catch {}
134
128
  }
135
129
  }),
136
130
  );
@@ -138,11 +132,10 @@ export const openUrlsTool = defineBrowserTool({
138
132
  const all: ReadonlyArray<TabResult> = [...settled, ...failures];
139
133
  const okTabs = all.filter((r) => r.ok);
140
134
  const failTabs = all.filter((r) => !r.ok);
141
- // Phase 3: activate the last successfully-opened tab so the user sees something.
142
135
  if (okTabs.length > 0) {
143
136
  const last = okTabs[okTabs.length - 1];
144
137
  if (last) {
145
- await client.session().callBrowser("Target.activateTarget", { targetId: last.targetId });
138
+ await cdpCallBrowser(client, "Target.activateTarget", { targetId: last.targetId });
146
139
  }
147
140
  }
148
141
  const lines: string[] = [];
@@ -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({
@@ -25,6 +28,7 @@ export const httpGetTool = defineBrowserTool({
25
28
  "No JS rendering — for SPAs use browser_navigate.",
26
29
  ],
27
30
  parameters: HttpGetArgs,
31
+ concurrency: "parallel",
28
32
  ensureAlive: false,
29
33
  async handler(args): Promise<Result<ToolOk, ToolErr>> {
30
34
  const ac = new AbortController();
@@ -96,11 +100,6 @@ const renderNetworkMarkdown = (
96
100
 
97
101
  const failed = records.filter((r) => r.failed || (r.status !== undefined && r.status >= 500)).length;
98
102
  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
103
  const showWindow = (() => {
105
104
  const finished = records.filter((r) => r.durationMs !== undefined);
106
105
  if (finished.length === 0) return "";
@@ -129,7 +128,6 @@ const renderNetworkMarkdown = (
129
128
  return `${headerLines.join("\n")}\n\n${rows}${more}`;
130
129
  }
131
130
 
132
- // Expanded: full table + optional body sections.
133
131
  const tableHeader = `| # | Method | Status | URL | Type | ms |\n|---|---|---|---|---|---|`;
134
132
  const tableRows = records
135
133
  .map((r, i) => {
@@ -167,6 +165,57 @@ type NetworkDetails = {
167
165
  includeBodies: boolean;
168
166
  };
169
167
 
168
+ const asNetworkRecord = (v: unknown): NetworkRecord | undefined => {
169
+ const o = asRecord(v);
170
+ if (o === undefined) return undefined;
171
+ const method = asString(o["method"]);
172
+ const url = asString(o["url"]);
173
+ const type = asString(o["type"]);
174
+ if (method === undefined || url === undefined || type === undefined) return undefined;
175
+ const status = asNumber(o["status"]);
176
+ const statusText = asString(o["statusText"]);
177
+ const mimeType = asString(o["mimeType"]);
178
+ const durationMs = asNumber(o["durationMs"]);
179
+ const responseBodySize = asNumber(o["responseBodySize"]);
180
+ const failed = asBoolean(o["failed"]);
181
+ const errorText = asString(o["errorText"]);
182
+ const bodyRaw = o["body"];
183
+ const body = typeof bodyRaw === "string" || bodyRaw === null ? bodyRaw : undefined;
184
+ return {
185
+ requestId: asString(o["requestId"]) ?? "",
186
+ method,
187
+ url,
188
+ type,
189
+ requestStartedMs: asNumber(o["requestStartedMs"]) ?? 0,
190
+ requestBodySize: asNumber(o["requestBodySize"]) ?? 0,
191
+ ...(status !== undefined ? { status } : {}),
192
+ ...(statusText !== undefined ? { statusText } : {}),
193
+ ...(mimeType !== undefined ? { mimeType } : {}),
194
+ ...(durationMs !== undefined ? { durationMs } : {}),
195
+ ...(responseBodySize !== undefined ? { responseBodySize } : {}),
196
+ ...(failed !== undefined ? { failed } : {}),
197
+ ...(errorText !== undefined ? { errorText } : {}),
198
+ ...(body !== undefined ? { body } : {}),
199
+ };
200
+ };
201
+
202
+ const asNetworkDetails = (v: unknown): NetworkDetails | undefined => {
203
+ const o = asRecord(v);
204
+ if (o === undefined) return undefined;
205
+ const requests = asArrayOf(o["requests"], asNetworkRecord);
206
+ if (requests === undefined) return undefined;
207
+ const fullOutputPath = asString(o["fullOutputPath"]);
208
+ return {
209
+ total: asNumber(o["total"]) ?? requests.length,
210
+ returned: asNumber(o["returned"]) ?? requests.length,
211
+ bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false,
212
+ truncated: asBoolean(o["truncated"]) ?? false,
213
+ requests,
214
+ includeBodies: asBoolean(o["includeBodies"]) ?? false,
215
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
216
+ };
217
+ };
218
+
170
219
  export const networkRequestsTool = defineBrowserTool({
171
220
  name: "browser_network_requests",
172
221
  label: "Browser Network Requests",
@@ -180,6 +229,7 @@ export const networkRequestsTool = defineBrowserTool({
180
229
  "If bufferOverflowed:true in the result, older events were dropped (capacity 500 records/tab).",
181
230
  ],
182
231
  parameters: NetworkRequestsArgs,
232
+ concurrency: "parallel",
183
233
 
184
234
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
185
235
  const session = client.session();
@@ -197,12 +247,12 @@ export const networkRequestsTool = defineBrowserTool({
197
247
  const timer = setTimeout(() => ac.abort(), BODY_BUDGET_MS);
198
248
  const fills = drained.records.map(async (r) => {
199
249
  if (ac.signal.aborted) return;
200
- const bodyRes = await session.call("Network.getResponseBody", { requestId: r.requestId });
250
+ const bodyRes = await cdpCall(client, "Network.getResponseBody", { requestId: r.requestId });
201
251
  if (!bodyRes.success) {
202
252
  r.body = null;
203
253
  return;
204
254
  }
205
- const b = bodyRes.data as { body: string; base64Encoded?: boolean };
255
+ const b = bodyRes.data;
206
256
  let body = b.base64Encoded ? Buffer.from(b.body, "base64").toString("utf8") : b.body;
207
257
  if (body.length > PER_BODY_CAP) body = body.slice(0, PER_BODY_CAP) + "…";
208
258
  r.body = body;
@@ -215,7 +265,7 @@ export const networkRequestsTool = defineBrowserTool({
215
265
  includeBodies: args.includeResponseBodies ?? false,
216
266
  total: drained.total,
217
267
  bufferOverflowed: drained.bufferOverflowed,
218
- expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans
268
+ expanded: true,
219
269
  });
220
270
  const trunc = await applyTruncation(text, "network");
221
271
 
@@ -233,7 +283,7 @@ export const networkRequestsTool = defineBrowserTool({
233
283
  },
234
284
 
235
285
  renderResult(result, expanded, theme) {
236
- const details = result.details as NetworkDetails | undefined;
286
+ const details = asNetworkDetails(result.details);
237
287
  if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0);
238
288
 
239
289
  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",
@@ -17,6 +34,7 @@ export const pageInfoTool = defineBrowserTool({
17
34
  "browser_page_info auto-detects alert, confirm, prompt, and beforeunload dialogs.",
18
35
  ],
19
36
  parameters: Type.Object({}),
37
+ concurrency: "parallel",
20
38
  async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
21
39
  const r = await client.pageInfo();
22
40
  if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -46,6 +64,7 @@ export const waitTool = defineBrowserTool({
46
64
  promptSnippet: "Wait N seconds",
47
65
  promptGuidelines: ["Use sparingly — prefer browser_wait_for_load when waiting for a page load."],
48
66
  parameters: WaitArgs,
67
+ concurrency: "parallel",
49
68
  async handler(args, { signal }): Promise<Result<ToolOk, ToolErr>> {
50
69
  try {
51
70
  await sleep(Math.round(args.seconds * 1000), signal);
@@ -71,12 +90,13 @@ export const waitForLoadTool = defineBrowserTool({
71
90
  "Returns when readyState becomes 'complete' OR the timeout elapses.",
72
91
  ],
73
92
  parameters: WaitForLoadArgs,
93
+ concurrency: "serialized",
74
94
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
75
95
  const timeoutMs = (args.timeout ?? 15) * 1000;
76
96
  const start = Date.now();
77
97
  while (Date.now() - start < timeoutMs) {
78
98
  if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
79
- const r = await client.evaluateJs(safeJs`document.readyState`);
99
+ const r = await evalJs(client, safeJs`document.readyState`);
80
100
  if (r.success && r.data === "complete") {
81
101
  const ms = Date.now() - start;
82
102
  return ok({ text: `Page loaded in ${Math.round(ms / 100) / 10}s`, details: { ms } });
@@ -106,6 +126,7 @@ export const waitForTool = defineBrowserTool({
106
126
  "Returns a typed timeout error if the condition isn't met within the timeout.",
107
127
  ],
108
128
  parameters: WaitForArgs,
129
+ concurrency: "parallel",
109
130
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
110
131
  if (args.selector === undefined && args.text === undefined) {
111
132
  return err({ kind: "invalid_state", message: "Provide a selector or text to wait for." });
@@ -119,7 +140,7 @@ export const waitForTool = defineBrowserTool({
119
140
  const start = Date.now();
120
141
  while (Date.now() - start < timeoutMs) {
121
142
  if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
122
- const r = await client.evaluateJs(expr);
143
+ const r = await evalJs(client, expr);
123
144
  if (r.success && r.data === true) {
124
145
  const ms = Date.now() - start;
125
146
  const what = args.selector !== undefined ? `${args.selector}${gone ? " gone" : ""}` : `text "${args.text}"`;