pi-browser-harness 0.3.2 → 0.4.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 +29 -0
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +32 -250
- package/src/cdp/network-buffer.ts +213 -0
- package/src/cdp/ownership.ts +72 -0
- package/src/cdp/session.ts +60 -14
- package/src/cdp/types.ts +1 -0
- package/src/client.ts +75 -4
- package/src/domains/click.ts +6 -5
- package/src/domains/js.ts +65 -6
- package/src/domains/navigate.ts +1 -1
- package/src/domains/network.ts +191 -20
- package/src/domains/screenshot.ts +8 -5
- package/src/domains/snapshot.ts +421 -0
- package/src/domains/tabs.ts +113 -39
- package/src/index.ts +17 -1
- package/src/registry.ts +6 -3
- package/src/state.ts +6 -1
package/src/cdp/session.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { type Result,
|
|
2
|
-
import {
|
|
1
|
+
import { type Result, ok } from "../util/result";
|
|
2
|
+
import type { CdpError } from "./errors";
|
|
3
3
|
import type { DialogInfo } from "./types";
|
|
4
|
-
import {
|
|
4
|
+
import type { OwnershipRegistry } from "./ownership";
|
|
5
5
|
import type { CdpTransport } from "./transport";
|
|
6
|
+
import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
|
|
6
7
|
|
|
7
8
|
export type CdpSession = {
|
|
8
9
|
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
|
|
@@ -13,13 +14,18 @@ export type CdpSession = {
|
|
|
13
14
|
callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
14
15
|
takeDialog(): DialogInfo | null;
|
|
15
16
|
drainPageInfoInvalidations(): boolean;
|
|
17
|
+
drainNetworkBuffer(filter: NetworkFilter): DrainResult;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
|
-
export const createCdpSession = (
|
|
20
|
+
export const createCdpSession = (
|
|
21
|
+
transport: CdpTransport,
|
|
22
|
+
ownership?: OwnershipRegistry,
|
|
23
|
+
): CdpSession => {
|
|
19
24
|
let sessionId: string | null = null;
|
|
20
25
|
let targetId: string | null = null;
|
|
21
26
|
let dialog: DialogInfo | null = null;
|
|
22
27
|
let pageInfoDirty = false;
|
|
28
|
+
const networkBuffer = createNetworkBuffer();
|
|
23
29
|
|
|
24
30
|
let activeConsumer: Promise<void> = Promise.resolve();
|
|
25
31
|
|
|
@@ -41,6 +47,14 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
41
47
|
if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
|
|
42
48
|
pageInfoDirty = true;
|
|
43
49
|
}
|
|
50
|
+
if (ev.method === "Target.targetDestroyed" && ownership) {
|
|
51
|
+
const params = ev.params as { targetId?: string } | undefined;
|
|
52
|
+
if (params?.targetId) ownership.remove(params.targetId);
|
|
53
|
+
}
|
|
54
|
+
if (ev.method === "Network.requestWillBeSent") networkBuffer.ingestRequestWillBeSent(ev.params);
|
|
55
|
+
else if (ev.method === "Network.responseReceived") networkBuffer.ingestResponseReceived(ev.params);
|
|
56
|
+
else if (ev.method === "Network.loadingFinished") networkBuffer.ingestLoadingFinished(ev.params);
|
|
57
|
+
else if (ev.method === "Network.loadingFailed") networkBuffer.ingestLoadingFailed(ev.params);
|
|
44
58
|
}
|
|
45
59
|
};
|
|
46
60
|
|
|
@@ -68,7 +82,7 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
68
82
|
// Switching to Promise.all over a single WS pipelines the round-trips and
|
|
69
83
|
// saves ~3× on tab-switch latency. Defer until session.ts has tests.
|
|
70
84
|
const enableDomains = async (sid: string): Promise<void> => {
|
|
71
|
-
for (const d of ["Page", "DOM", "Runtime", "Network"]) {
|
|
85
|
+
for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility"]) {
|
|
72
86
|
await transport.request(`${d}.enable`, {}, { sessionId: sid });
|
|
73
87
|
}
|
|
74
88
|
};
|
|
@@ -81,25 +95,51 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
81
95
|
// as a clear Error in normal use.
|
|
82
96
|
return {
|
|
83
97
|
async attachFirstPage() {
|
|
98
|
+
// Subscribe to Target.* events so we can react to targetDestroyed.
|
|
99
|
+
// Best-effort: failing to enable discovery is not fatal for attach.
|
|
100
|
+
await transport.request("Target.setDiscoverTargets", { discover: true }, { sessionId: null });
|
|
101
|
+
|
|
84
102
|
const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
|
|
85
103
|
if (!targets.success) return targets;
|
|
86
104
|
const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
105
|
+
const allPages = data.targetInfos.filter((t) => t.type === "page");
|
|
106
|
+
// Reconcile the persisted ownership set against live targets — drop dead IDs.
|
|
107
|
+
if (ownership) {
|
|
108
|
+
const live = new Set(allPages.map((p) => p.targetId));
|
|
109
|
+
const survivors = ownership.list().filter((id) => live.has(id));
|
|
110
|
+
if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
|
|
111
|
+
const hw = ownership.harnessWindow();
|
|
112
|
+
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Prefer attaching to a tab this session already owns. Falls back to
|
|
116
|
+
// creating a fresh harness-owned tab in a dedicated window — never
|
|
117
|
+
// grabs the user's foreground tab.
|
|
118
|
+
let pickTargetId: string | undefined;
|
|
119
|
+
if (ownership) {
|
|
120
|
+
const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
|
|
121
|
+
pickTargetId = ownedLive[0];
|
|
122
|
+
}
|
|
123
|
+
if (!pickTargetId) {
|
|
124
|
+
const createParams: Record<string, unknown> = { url: "about:blank" };
|
|
125
|
+
if (ownership) createParams["newWindow"] = true;
|
|
126
|
+
const created = await transport.request("Target.createTarget", createParams, { sessionId: null });
|
|
90
127
|
if (!created.success) return created;
|
|
91
128
|
const c = created.data as { targetId: string };
|
|
92
|
-
|
|
129
|
+
pickTargetId = c.targetId;
|
|
130
|
+
if (ownership) {
|
|
131
|
+
ownership.setHarnessWindow(c.targetId);
|
|
132
|
+
ownership.add(c.targetId);
|
|
133
|
+
}
|
|
93
134
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null });
|
|
135
|
+
|
|
136
|
+
const attached = await transport.request("Target.attachToTarget", { targetId: pickTargetId, flatten: true }, { sessionId: null });
|
|
97
137
|
if (!attached.success) return attached;
|
|
98
138
|
const a = attached.data as { sessionId: string };
|
|
99
139
|
sessionId = a.sessionId;
|
|
100
|
-
targetId =
|
|
140
|
+
targetId = pickTargetId;
|
|
101
141
|
await enableDomains(a.sessionId);
|
|
102
|
-
return ok({ targetId:
|
|
142
|
+
return ok({ targetId: pickTargetId, sessionId: a.sessionId });
|
|
103
143
|
},
|
|
104
144
|
async switchTo(tid) {
|
|
105
145
|
const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
|
|
@@ -110,6 +150,9 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
110
150
|
sessionId = a.sessionId;
|
|
111
151
|
targetId = tid;
|
|
112
152
|
pageInfoDirty = true;
|
|
153
|
+
// Buffer is page-scoped: on switch, the network history of the previous
|
|
154
|
+
// tab is no longer relevant to what the agent is now looking at.
|
|
155
|
+
networkBuffer.clear();
|
|
113
156
|
await enableDomains(a.sessionId);
|
|
114
157
|
return ok(undefined);
|
|
115
158
|
},
|
|
@@ -135,5 +178,8 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
|
135
178
|
pageInfoDirty = false;
|
|
136
179
|
return dirty;
|
|
137
180
|
},
|
|
181
|
+
drainNetworkBuffer(filter) {
|
|
182
|
+
return networkBuffer.drain(filter);
|
|
183
|
+
},
|
|
138
184
|
};
|
|
139
185
|
};
|
package/src/cdp/types.ts
CHANGED
package/src/client.ts
CHANGED
|
@@ -6,11 +6,20 @@ import { type CdpError, cdpError } from "./cdp/errors";
|
|
|
6
6
|
import type { CdpTransport } from "./cdp/transport";
|
|
7
7
|
import { createCdpTransport } from "./cdp/transport";
|
|
8
8
|
import { type CdpSession, createCdpSession } from "./cdp/session";
|
|
9
|
+
import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
|
|
9
10
|
import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
|
|
10
11
|
|
|
11
12
|
export type BrowserClientOptions = {
|
|
12
13
|
readonly namespace: string;
|
|
13
14
|
readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
|
|
15
|
+
readonly initialOwnership?: {
|
|
16
|
+
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
17
|
+
readonly harnessWindowTargetId?: string;
|
|
18
|
+
};
|
|
19
|
+
readonly onOwnershipChange?: (snapshot: {
|
|
20
|
+
readonly ownedTargetIds: ReadonlyArray<string>;
|
|
21
|
+
readonly harnessWindowTargetId: string | undefined;
|
|
22
|
+
}) => void;
|
|
14
23
|
};
|
|
15
24
|
|
|
16
25
|
export type BrowserClient = {
|
|
@@ -25,6 +34,9 @@ export type BrowserClient = {
|
|
|
25
34
|
listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
|
|
26
35
|
switchTab(targetId: string): Promise<Result<void, CdpError>>;
|
|
27
36
|
newTab(url?: string): Promise<Result<string, CdpError>>;
|
|
37
|
+
closeTab(targetId: string): Promise<Result<void, CdpError>>;
|
|
38
|
+
owns(targetId: string): boolean;
|
|
39
|
+
ownership(): OwnershipRegistry;
|
|
28
40
|
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
29
41
|
session(): CdpSession;
|
|
30
42
|
transport(): CdpTransport;
|
|
@@ -66,7 +78,24 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
|
|
|
66
78
|
|
|
67
79
|
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
|
|
68
80
|
const transport = createCdpTransport();
|
|
69
|
-
const
|
|
81
|
+
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
|
|
82
|
+
if (opts.initialOwnership?.ownedTargetIds !== undefined) {
|
|
83
|
+
ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
|
|
84
|
+
}
|
|
85
|
+
if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
|
|
86
|
+
ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
|
|
87
|
+
}
|
|
88
|
+
const ownership = createOwnershipRegistry(ownershipInit);
|
|
89
|
+
if (opts.onOwnershipChange) {
|
|
90
|
+
const cb = opts.onOwnershipChange;
|
|
91
|
+
ownership.onChange(() => {
|
|
92
|
+
cb({
|
|
93
|
+
ownedTargetIds: ownership.list(),
|
|
94
|
+
harnessWindowTargetId: ownership.harnessWindow(),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const session = createCdpSession(transport, ownership);
|
|
70
99
|
const mutationMutex = createMutex();
|
|
71
100
|
let lastHealth = 0;
|
|
72
101
|
let pageCache: { readonly info: PageInfo; readonly at: number } | null = null;
|
|
@@ -186,7 +215,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
186
215
|
const tabs = data.targetInfos
|
|
187
216
|
.filter((t) => t.type === "page")
|
|
188
217
|
.filter((t) => includeInternal || !t.url.startsWith("chrome://"))
|
|
189
|
-
.map((t): TabInfo => ({
|
|
218
|
+
.map((t): TabInfo => ({
|
|
219
|
+
targetId: t.targetId,
|
|
220
|
+
title: t.title,
|
|
221
|
+
url: t.url,
|
|
222
|
+
owned: ownership.has(t.targetId),
|
|
223
|
+
}));
|
|
224
|
+
// Reconcile any persisted owned ids that no longer exist as live page targets.
|
|
225
|
+
const live = new Set(tabs.map((t) => t.targetId));
|
|
226
|
+
const owned = ownership.list();
|
|
227
|
+
const survivors = owned.filter((id) => live.has(id));
|
|
228
|
+
if (survivors.length !== owned.length) ownership.replaceAll(survivors);
|
|
229
|
+
const hw = ownership.harnessWindow();
|
|
230
|
+
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
190
231
|
return ok(tabs);
|
|
191
232
|
};
|
|
192
233
|
|
|
@@ -204,9 +245,30 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
204
245
|
};
|
|
205
246
|
|
|
206
247
|
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
|
|
207
|
-
|
|
248
|
+
// Verify the recorded harness window still exists. Querying the live
|
|
249
|
+
// target list also gives listTabs's reconciliation a chance to run.
|
|
250
|
+
const tabsResult = await listTabs(true);
|
|
251
|
+
if (!tabsResult.success) return tabsResult;
|
|
252
|
+
const live = new Set(tabsResult.data.map((t) => t.targetId));
|
|
253
|
+
const hw = ownership.harnessWindow();
|
|
254
|
+
|
|
255
|
+
const params: Record<string, unknown> = { url: "about:blank" };
|
|
256
|
+
if (hw && live.has(hw)) {
|
|
257
|
+
// Open as a child of the harness window's seed tab — Chrome places
|
|
258
|
+
// it in the same window, giving us visual grouping for free.
|
|
259
|
+
params["openerId"] = hw;
|
|
260
|
+
} else {
|
|
261
|
+
// Either fresh session or the harness window was closed by the user.
|
|
262
|
+
// Spawn a new dedicated window.
|
|
263
|
+
params["newWindow"] = true;
|
|
264
|
+
}
|
|
265
|
+
const created = await session.callBrowser("Target.createTarget", params);
|
|
208
266
|
if (!created.success) return created;
|
|
209
267
|
const c = created.data as { targetId: string };
|
|
268
|
+
|
|
269
|
+
if (params["newWindow"]) ownership.setHarnessWindow(c.targetId);
|
|
270
|
+
ownership.add(c.targetId);
|
|
271
|
+
|
|
210
272
|
const switched = await switchTab(c.targetId);
|
|
211
273
|
if (!switched.success) return switched;
|
|
212
274
|
if (url && url !== "about:blank") {
|
|
@@ -217,6 +279,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
217
279
|
return ok(c.targetId);
|
|
218
280
|
};
|
|
219
281
|
|
|
282
|
+
const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
|
|
283
|
+
const r = await session.callBrowser("Target.closeTarget", { targetId });
|
|
284
|
+
if (!r.success) return r;
|
|
285
|
+
ownership.remove(targetId);
|
|
286
|
+
return ok(undefined);
|
|
287
|
+
};
|
|
288
|
+
|
|
220
289
|
const status = (): DaemonStatus => ({
|
|
221
290
|
alive: transport.state() === "open" && session.current() !== null,
|
|
222
291
|
sessionId: session.current()?.sessionId ?? null,
|
|
@@ -229,7 +298,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
229
298
|
ensureAlive, status, start, stop,
|
|
230
299
|
evaluateJs, pageInfo,
|
|
231
300
|
takeDialog: () => session.takeDialog(),
|
|
232
|
-
listTabs, switchTab, newTab,
|
|
301
|
+
listTabs, switchTab, newTab, closeTab,
|
|
302
|
+
owns: (id: string) => ownership.has(id),
|
|
303
|
+
ownership: () => ownership,
|
|
233
304
|
current: () => session.current(),
|
|
234
305
|
session: () => session,
|
|
235
306
|
transport: () => transport,
|
package/src/domains/click.ts
CHANGED
|
@@ -41,14 +41,15 @@ export const clickTool = defineBrowserTool({
|
|
|
41
41
|
name: "browser_click",
|
|
42
42
|
label: "Browser Click",
|
|
43
43
|
description:
|
|
44
|
-
"Click at viewport CSS-pixel coordinates. Compositor-level click works through iframes, shadow DOM, and cross-origin content.
|
|
44
|
+
"Click at viewport CSS-pixel coordinates. Compositor-level click works through iframes, shadow DOM, and cross-origin content. Get coordinates from browser_snapshot's @(x,y) hints — do not screenshot to find click targets.",
|
|
45
45
|
promptSnippet: "Click at pixel coordinates (x, y) on the page",
|
|
46
46
|
promptGuidelines: [
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
47
|
+
"Get (x, y) from browser_snapshot — the outline shows '@(x,y)' for every interactive element. NO screenshot needed.",
|
|
48
|
+
"If the target isn't an interactive role in the snapshot, fall back to browser_execute_js with `el.getBoundingClientRect()` — still NO screenshot.",
|
|
49
|
+
"Coordinates are viewport CSS pixels (not device pixels).",
|
|
50
|
+
"After clicking, verify with browser_snapshot (or browser_execute_js for a specific value). Only screenshot if you need to confirm visual rendering.",
|
|
50
51
|
"Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content — no selector needed.",
|
|
51
|
-
"If a click doesn't register,
|
|
52
|
+
"If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots showing exact click positions (debug only).",
|
|
52
53
|
"For React/Vue components that don't respond to clicks, try browser_dispatch_key to send DOM-level events.",
|
|
53
54
|
],
|
|
54
55
|
parameters: ClickArgs,
|
package/src/domains/js.ts
CHANGED
|
@@ -3,10 +3,20 @@ import { resolve, isAbsolute } from "node:path";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
+
import { Markdown, Text } from "@mariozechner/pi-tui";
|
|
7
|
+
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
6
8
|
import { type Result, err, ok } from "../util/result";
|
|
7
9
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
8
10
|
import { applyTruncation } from "../util/truncate";
|
|
9
11
|
|
|
12
|
+
const COMPACT_PREVIEW_BYTES = 120;
|
|
13
|
+
|
|
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
|
+
|
|
10
20
|
// AsyncFunction constructor — the documented Node.js mechanism for compiling
|
|
11
21
|
// arbitrary user-supplied source. Equivalent to `new Function` but produces an
|
|
12
22
|
// async function we can `await`. The full RCE risk is contained at the source
|
|
@@ -29,11 +39,15 @@ export const executeJsTool = defineBrowserTool({
|
|
|
29
39
|
name: "browser_execute_js",
|
|
30
40
|
label: "Browser Execute JS",
|
|
31
41
|
description:
|
|
32
|
-
"
|
|
33
|
-
promptSnippet: "Execute JS in the page (
|
|
42
|
+
"Surgical reads from the DOM. The cheapest, most precise way to extract a specific element's text, attribute, value, or geometry. Prefer over browser_screenshot for ANY data extraction or coordinate lookup. `return X` is auto-wrapped in an IIFE. Always use safeJs / JSON.stringify when interpolating untrusted strings.",
|
|
43
|
+
promptSnippet: "Execute JS in the page — surgical DOM reads (preferred over screenshot)",
|
|
34
44
|
promptGuidelines: [
|
|
45
|
+
"DEFAULT for extracting a specific value: `document.querySelector('.price').innerText`, `input.value`, `el.getAttribute('aria-label')`, etc.",
|
|
46
|
+
"For click coordinates not already in browser_snapshot: `JSON.stringify(document.querySelector('SELECTOR').getBoundingClientRect())` — beats a screenshot every time.",
|
|
47
|
+
"For 'is this element visible / enabled / checked / focused', a one-liner here beats a screenshot every time.",
|
|
48
|
+
"DO NOT call browser_screenshot to read a value or check element state. Use this tool.",
|
|
35
49
|
"`return foo` is auto-wrapped in an IIFE — both `foo` and `(() => foo)()` work.",
|
|
36
|
-
"For iframes,
|
|
50
|
+
"For iframes, first run `Object.values(document.querySelectorAll('iframe'))` to find the iframe, then pass its targetId.",
|
|
37
51
|
"Result must be JSON-serializable (Runtime.evaluate returnByValue=true).",
|
|
38
52
|
],
|
|
39
53
|
parameters: ExecuteJsArgs,
|
|
@@ -42,13 +56,58 @@ export const executeJsTool = defineBrowserTool({
|
|
|
42
56
|
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
43
57
|
const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data);
|
|
44
58
|
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
|
+
let pretty: string | undefined;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(valueStr);
|
|
64
|
+
pretty = JSON.stringify(parsed, null, 2);
|
|
65
|
+
} catch {
|
|
66
|
+
pretty = undefined;
|
|
67
|
+
}
|
|
45
68
|
return ok({
|
|
46
69
|
text: truncated.text,
|
|
47
|
-
details:
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
details: {
|
|
71
|
+
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
|
+
full: valueStr.length > 200_000 ? valueStr.slice(0, 200_000) : valueStr,
|
|
75
|
+
...(pretty !== undefined ? { pretty: pretty.length > 200_000 ? pretty.slice(0, 200_000) : pretty } : {}),
|
|
76
|
+
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
77
|
+
},
|
|
50
78
|
});
|
|
51
79
|
},
|
|
80
|
+
|
|
81
|
+
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);
|
|
86
|
+
|
|
87
|
+
const len = details.valueLength ?? 0;
|
|
88
|
+
const full = details.full ?? "";
|
|
89
|
+
const isJson = details.pretty !== undefined;
|
|
90
|
+
|
|
91
|
+
if (!expanded) {
|
|
92
|
+
const preview = full.length > COMPACT_PREVIEW_BYTES ? full.slice(0, COMPACT_PREVIEW_BYTES) + "…" : full;
|
|
93
|
+
const md = [
|
|
94
|
+
`**${formatBytes(len)}** ${isJson ? "JSON" : "value"}`,
|
|
95
|
+
"```",
|
|
96
|
+
preview,
|
|
97
|
+
"```",
|
|
98
|
+
keyHint("app.tools.expand", "to expand"),
|
|
99
|
+
].join("\n");
|
|
100
|
+
return new Markdown(md, 0, 0, getMarkdownTheme());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const body = details.pretty ?? full;
|
|
104
|
+
const fence = isJson ? "```json" : "```";
|
|
105
|
+
const tail = details.fullOutputPath
|
|
106
|
+
? `\n\nFull value at \`${details.fullOutputPath}\` · ${keyHint("app.tools.expand", "to collapse")}`
|
|
107
|
+
: `\n\n${keyHint("app.tools.expand", "to collapse")}`;
|
|
108
|
+
const md = [`**${formatBytes(len)}** ${isJson ? "JSON" : "value"}`, fence, body, "```", tail].join("\n");
|
|
109
|
+
return new Markdown(md, 0, 0, getMarkdownTheme());
|
|
110
|
+
},
|
|
52
111
|
});
|
|
53
112
|
|
|
54
113
|
const RunScriptArgs = Type.Object({
|
package/src/domains/navigate.ts
CHANGED
|
@@ -75,7 +75,7 @@ export const openUrlsTool = defineBrowserTool({
|
|
|
75
75
|
promptSnippet: "Open multiple URLs in new tabs (parallel)",
|
|
76
76
|
promptGuidelines: [
|
|
77
77
|
"Use after web_search to open citations in parallel.",
|
|
78
|
-
"After opening, use browser_list_tabs / browser_switch_tab
|
|
78
|
+
"After opening, use browser_list_tabs / browser_switch_tab to navigate, then browser_snapshot to inspect each tab.",
|
|
79
79
|
"Use browser_wait_for_load on a tab before extracting data from SPAs.",
|
|
80
80
|
],
|
|
81
81
|
parameters: OpenUrlsArgs,
|
package/src/domains/network.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import { Markdown, Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
2
4
|
import { type Result, err, ok } from "../util/result";
|
|
3
5
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
6
|
import { applyTruncation } from "../util/truncate";
|
|
7
|
+
import type { NetworkRecord } from "../cdp/network-buffer";
|
|
5
8
|
|
|
6
9
|
const HttpGetArgs = Type.Object({
|
|
7
10
|
url: Type.String({ description: "URL to GET" }),
|
|
@@ -49,32 +52,200 @@ export const httpGetTool = defineBrowserTool({
|
|
|
49
52
|
},
|
|
50
53
|
});
|
|
51
54
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
Type.
|
|
55
|
+
const NetworkRequestsArgs = Type.Object({
|
|
56
|
+
urlPattern: Type.Optional(
|
|
57
|
+
Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /api\\\\.example/) for regex." }),
|
|
58
|
+
),
|
|
59
|
+
methodFilter: Type.Optional(
|
|
60
|
+
Type.Array(Type.String(), { description: 'HTTP methods to include, e.g. ["GET","POST"].' }),
|
|
61
|
+
),
|
|
62
|
+
statusFilter: Type.Optional(
|
|
63
|
+
Type.Object(
|
|
64
|
+
{
|
|
65
|
+
min: Type.Optional(Type.Integer({ minimum: 100, maximum: 599 })),
|
|
66
|
+
max: Type.Optional(Type.Integer({ minimum: 100, maximum: 599 })),
|
|
67
|
+
},
|
|
68
|
+
{ description: "Inclusive HTTP status range." },
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
resourceTypes: Type.Optional(
|
|
72
|
+
Type.Array(Type.String(), { description: 'CDP resource types: "Document","Stylesheet","Image","Media","Font","Script","TextTrack","XHR","Fetch","EventSource","WebSocket","Manifest","SignedExchange","Ping","CSPViolationReport","Preflight","Other".' }),
|
|
73
|
+
),
|
|
74
|
+
sinceMs: Type.Optional(
|
|
75
|
+
Type.Integer({ minimum: 0, description: "Only requests started in the last N ms." }),
|
|
76
|
+
),
|
|
77
|
+
limit: Type.Optional(
|
|
78
|
+
Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
|
|
79
|
+
),
|
|
80
|
+
includeResponseBodies: Type.Optional(
|
|
81
|
+
Type.Boolean({ default: false, description: "Fetch response bodies via Network.getResponseBody. Costs one CDP call per matched request." }),
|
|
55
82
|
),
|
|
56
|
-
limit: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 500 })),
|
|
57
83
|
});
|
|
58
84
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
85
|
+
const BODY_BUDGET_MS = 5_000;
|
|
86
|
+
const PER_BODY_CAP = 50_000;
|
|
87
|
+
|
|
88
|
+
const renderNetworkMarkdown = (
|
|
89
|
+
records: ReadonlyArray<NetworkRecord>,
|
|
90
|
+
opts: { includeBodies: boolean; total: number; bufferOverflowed: boolean; expanded: boolean },
|
|
91
|
+
): string => {
|
|
92
|
+
if (records.length === 0) {
|
|
93
|
+
if (opts.bufferOverflowed) return "_No matching requests. (Buffer overflowed since last drain — some events were dropped.)_";
|
|
94
|
+
return "_No matching requests captured yet._";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const failed = records.filter((r) => r.failed || (r.status !== undefined && r.status >= 500)).length;
|
|
98
|
+
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
|
+
const showWindow = (() => {
|
|
105
|
+
const finished = records.filter((r) => r.durationMs !== undefined);
|
|
106
|
+
if (finished.length === 0) return "";
|
|
107
|
+
const span = Math.max(...records.map((r) => r.requestStartedMs)) - Math.min(...records.map((r) => r.requestStartedMs));
|
|
108
|
+
return ` · ${(span / 1000).toFixed(1)}s window`;
|
|
109
|
+
})();
|
|
110
|
+
|
|
111
|
+
const headerLines: string[] = [
|
|
112
|
+
`**${records.length} requests** · ${failed} failed · ${formatBytes(totalBytes)}${showWindow}`,
|
|
113
|
+
];
|
|
114
|
+
if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older events dropped");
|
|
115
|
+
if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
|
|
116
|
+
|
|
117
|
+
const compactRows = (records.length > 5 && !opts.expanded ? records.slice(0, 5) : records).map((r) => {
|
|
118
|
+
const status = r.failed ? "ERR" : r.status !== undefined ? String(r.status) : "—";
|
|
119
|
+
const warn = r.failed || (r.status !== undefined && r.status >= 500) ? " ⚠" : "";
|
|
120
|
+
const ms = r.durationMs !== undefined ? `${r.durationMs} ms` : "—";
|
|
121
|
+
const url = r.url.length > 60 ? r.url.slice(0, 57) + "…" : r.url;
|
|
122
|
+
return `\`${r.method.padEnd(4)} ${status.padStart(3)}\` ${url} _${ms}_${warn}`;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const rows = compactRows.join("\n");
|
|
126
|
+
|
|
127
|
+
if (!opts.expanded) {
|
|
128
|
+
const more = records.length > 5 ? `\n … ${records.length - 5} more` : "";
|
|
129
|
+
return `${headerLines.join("\n")}\n\n${rows}${more}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Expanded: full table + optional body sections.
|
|
133
|
+
const tableHeader = `| # | Method | Status | URL | Type | ms |\n|---|---|---|---|---|---|`;
|
|
134
|
+
const tableRows = records
|
|
135
|
+
.map((r, i) => {
|
|
136
|
+
const status = r.failed ? "ERR" : r.status !== undefined ? String(r.status) : "—";
|
|
137
|
+
const ms = r.durationMs !== undefined ? String(r.durationMs) : "—";
|
|
138
|
+
const url = r.url.length > 80 ? r.url.slice(0, 77) + "…" : r.url;
|
|
139
|
+
return `| ${i + 1} | ${r.method} | ${status} | \`${url}\` | ${r.type} | ${ms} |`;
|
|
140
|
+
})
|
|
141
|
+
.join("\n");
|
|
142
|
+
|
|
143
|
+
let bodySections = "";
|
|
144
|
+
if (opts.includeBodies) {
|
|
145
|
+
const sections: string[] = [];
|
|
146
|
+
records.forEach((r, i) => {
|
|
147
|
+
if (r.body === undefined) return;
|
|
148
|
+
const sizes = `${r.requestBodySize} B → ${r.responseBodySize ?? 0} B`;
|
|
149
|
+
const status = r.failed ? "failed" : r.status ?? "?";
|
|
150
|
+
const header = `── #${i + 1} ${r.method} ${r.url} (${status}, ${r.durationMs ?? "?"} ms, ${r.type}, ${sizes}) ──`;
|
|
151
|
+
const bodyText = r.body === null ? "_(body unavailable — already GC'd by Chrome)_" : "```\n" + r.body + "\n```";
|
|
152
|
+
sections.push(`${header}\n\n${bodyText}`);
|
|
153
|
+
});
|
|
154
|
+
if (sections.length > 0) bodySections = "\n\n" + sections.join("\n\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return [headerLines.join("\n"), "", tableHeader, tableRows, bodySections].filter((s) => s !== "").join("\n");
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
type NetworkDetails = {
|
|
161
|
+
total: number;
|
|
162
|
+
returned: number;
|
|
163
|
+
bufferOverflowed: boolean;
|
|
164
|
+
truncated: boolean;
|
|
165
|
+
fullOutputPath?: string;
|
|
166
|
+
requests: ReadonlyArray<NetworkRecord>;
|
|
167
|
+
includeBodies: boolean;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const networkRequestsTool = defineBrowserTool({
|
|
171
|
+
name: "browser_network_requests",
|
|
172
|
+
label: "Browser Network Requests",
|
|
62
173
|
description:
|
|
63
|
-
"
|
|
64
|
-
promptSnippet: "
|
|
174
|
+
"List recent network requests captured on the current tab since attach. Filter by URL pattern, method, status, resource type, or recency. Optionally include response bodies.",
|
|
175
|
+
promptSnippet: "Inspect recent network requests on the current page",
|
|
65
176
|
promptGuidelines: [
|
|
66
|
-
"
|
|
67
|
-
|
|
177
|
+
"Buffer is reset on tab switch — only requests on the current tab since attach are visible.",
|
|
178
|
+
'urlPattern is a substring; wrap in slashes for regex (e.g. "/\\\\.json$/").',
|
|
179
|
+
"includeResponseBodies:true costs an extra CDP call per matched request — use only when you need payloads.",
|
|
180
|
+
"If bufferOverflowed:true in the result, older events were dropped (capacity 500 records/tab).",
|
|
68
181
|
],
|
|
69
|
-
parameters:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
},
|
|
182
|
+
parameters: NetworkRequestsArgs,
|
|
183
|
+
|
|
184
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
185
|
+
const session = client.session();
|
|
186
|
+
const drained = session.drainNetworkBuffer({
|
|
187
|
+
...(args.urlPattern !== undefined ? { urlPattern: args.urlPattern } : {}),
|
|
188
|
+
...(args.methodFilter !== undefined ? { methodFilter: args.methodFilter } : {}),
|
|
189
|
+
...(args.statusFilter !== undefined ? { statusFilter: args.statusFilter } : {}),
|
|
190
|
+
...(args.resourceTypes !== undefined ? { resourceTypes: args.resourceTypes } : {}),
|
|
191
|
+
...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
|
|
192
|
+
limit: args.limit ?? 50,
|
|
78
193
|
});
|
|
194
|
+
|
|
195
|
+
if (args.includeResponseBodies) {
|
|
196
|
+
const ac = new AbortController();
|
|
197
|
+
const timer = setTimeout(() => ac.abort(), BODY_BUDGET_MS);
|
|
198
|
+
const fills = drained.records.map(async (r) => {
|
|
199
|
+
if (ac.signal.aborted) return;
|
|
200
|
+
const bodyRes = await session.call("Network.getResponseBody", { requestId: r.requestId });
|
|
201
|
+
if (!bodyRes.success) {
|
|
202
|
+
r.body = null;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const b = bodyRes.data as { body: string; base64Encoded?: boolean };
|
|
206
|
+
let body = b.base64Encoded ? Buffer.from(b.body, "base64").toString("utf8") : b.body;
|
|
207
|
+
if (body.length > PER_BODY_CAP) body = body.slice(0, PER_BODY_CAP) + "…";
|
|
208
|
+
r.body = body;
|
|
209
|
+
});
|
|
210
|
+
await Promise.allSettled(fills);
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const text = renderNetworkMarkdown(drained.records, {
|
|
215
|
+
includeBodies: args.includeResponseBodies ?? false,
|
|
216
|
+
total: drained.total,
|
|
217
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
218
|
+
expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans
|
|
219
|
+
});
|
|
220
|
+
const trunc = await applyTruncation(text, "network");
|
|
221
|
+
|
|
222
|
+
const details: NetworkDetails = {
|
|
223
|
+
total: drained.total,
|
|
224
|
+
returned: drained.records.length,
|
|
225
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
226
|
+
truncated: trunc.wasTruncated,
|
|
227
|
+
requests: drained.records,
|
|
228
|
+
includeBodies: args.includeResponseBodies ?? false,
|
|
229
|
+
};
|
|
230
|
+
if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
|
|
231
|
+
|
|
232
|
+
return ok({ text: trunc.text, details });
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
renderResult(result, expanded, theme) {
|
|
236
|
+
const details = result.details as NetworkDetails | undefined;
|
|
237
|
+
if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0);
|
|
238
|
+
|
|
239
|
+
const md = renderNetworkMarkdown(details.requests, {
|
|
240
|
+
includeBodies: details.includeBodies && expanded,
|
|
241
|
+
total: details.total,
|
|
242
|
+
bufferOverflowed: details.bufferOverflowed,
|
|
243
|
+
expanded,
|
|
244
|
+
});
|
|
245
|
+
const tail = expanded
|
|
246
|
+
? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
|
|
247
|
+
: `\n\n${keyHint("app.tools.expand", "to expand")}`;
|
|
248
|
+
|
|
249
|
+
return new Markdown(md + tail, 0, 0, getMarkdownTheme());
|
|
79
250
|
},
|
|
80
251
|
});
|