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.
- package/CHANGELOG.md +28 -0
- package/README.md +5 -9
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- 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
|
-
|
|
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
|
|
56
|
-
if (!r.success) return
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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,
|
|
198
|
+
client,
|
|
209
199
|
requireFromHere,
|
|
210
200
|
ac.signal,
|
|
211
201
|
(u: unknown) => {
|
|
212
|
-
if (
|
|
213
|
-
const 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 (
|
|
217
|
-
const txt =
|
|
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 {
|
|
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 (
|
|
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 =
|
|
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 =
|
|
243
|
-
|
|
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);
|
package/src/domains/keyboard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
47
|
-
if (!r.success) return
|
|
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
|
|
93
|
-
if (!down.success) return
|
|
94
|
-
//
|
|
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
|
|
100
|
-
if (!charEv.success) return
|
|
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
|
|
103
|
-
if (!up.success) return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
174
|
-
if (!r.success) return
|
|
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({
|
package/src/domains/navigate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
41
|
-
if (!nav.success) return
|
|
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
|
-
//
|
|
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().
|
|
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 =
|
|
116
|
-
const enabled = await client
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
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 {
|
|
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
|
|
138
|
+
await cdpCallBrowser(client, "Target.activateTarget", { targetId: last.targetId });
|
|
146
139
|
}
|
|
147
140
|
}
|
|
148
141
|
const lines: string[] = [];
|
package/src/domains/network.ts
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
|
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, {
|
package/src/domains/page.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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}"`;
|