pi-browser-harness 0.3.2 → 0.5.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 +45 -0
- package/README.md +150 -56
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +37 -248
- package/src/cdp/console-buffer.ts +263 -0
- package/src/cdp/network-buffer.ts +213 -0
- package/src/cdp/ownership.ts +72 -0
- package/src/cdp/session.ts +173 -26
- package/src/cdp/types.ts +1 -0
- package/src/client.ts +88 -12
- package/src/domains/click.ts +6 -5
- package/src/domains/console.ts +173 -0
- 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/prompt.ts +3 -0
- package/src/registry.ts +8 -3
- package/src/state.ts +6 -1
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,10 +78,27 @@ 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
|
-
let
|
|
101
|
+
let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
|
|
73
102
|
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
|
|
74
103
|
|
|
75
104
|
const start = async (): Promise<Result<void, CdpError>> => {
|
|
@@ -96,13 +125,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
96
125
|
return attached;
|
|
97
126
|
}
|
|
98
127
|
lastHealth = Date.now();
|
|
99
|
-
|
|
128
|
+
pageCaches.clear();
|
|
100
129
|
return ok(undefined);
|
|
101
130
|
};
|
|
102
131
|
|
|
103
132
|
const stop = async (): Promise<void> => {
|
|
104
133
|
await transport.close();
|
|
105
|
-
|
|
134
|
+
pageCaches.clear();
|
|
106
135
|
lastHealth = 0;
|
|
107
136
|
};
|
|
108
137
|
|
|
@@ -126,7 +155,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
126
155
|
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
|
|
127
156
|
const reattached = await session.attachFirstPage();
|
|
128
157
|
if (reattached.success) {
|
|
129
|
-
|
|
158
|
+
pageCaches.clear();
|
|
130
159
|
return ok(undefined);
|
|
131
160
|
}
|
|
132
161
|
await stop();
|
|
@@ -156,7 +185,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
156
185
|
|
|
157
186
|
const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => {
|
|
158
187
|
const dirty = session.drainPageInfoInvalidations();
|
|
159
|
-
|
|
188
|
+
const currentTid = session.current()?.targetId;
|
|
189
|
+
const cached = currentTid ? pageCaches.get(currentTid) : undefined;
|
|
190
|
+
if (cached && !dirty && Date.now() - cached.at < PAGE_INFO_TTL_MS) return ok(cached.info);
|
|
160
191
|
const expr = safeJs`JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
|
|
161
192
|
const raw = await evaluateJs(expr);
|
|
162
193
|
if (!raw.success) return raw;
|
|
@@ -169,7 +200,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
169
200
|
}
|
|
170
201
|
const info = parsePageInfoPayload(parsedRaw);
|
|
171
202
|
if (!info.success) return info;
|
|
172
|
-
|
|
203
|
+
if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() });
|
|
173
204
|
return ok(info.data);
|
|
174
205
|
};
|
|
175
206
|
|
|
@@ -186,14 +217,30 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
186
217
|
const tabs = data.targetInfos
|
|
187
218
|
.filter((t) => t.type === "page")
|
|
188
219
|
.filter((t) => includeInternal || !t.url.startsWith("chrome://"))
|
|
189
|
-
.map((t): TabInfo => ({
|
|
220
|
+
.map((t): TabInfo => ({
|
|
221
|
+
targetId: t.targetId,
|
|
222
|
+
title: t.title,
|
|
223
|
+
url: t.url,
|
|
224
|
+
owned: ownership.has(t.targetId),
|
|
225
|
+
}));
|
|
226
|
+
// Reconcile any persisted owned ids that no longer exist as live page targets.
|
|
227
|
+
const live = new Set(tabs.map((t) => t.targetId));
|
|
228
|
+
const owned = ownership.list();
|
|
229
|
+
const survivors = owned.filter((id) => live.has(id));
|
|
230
|
+
if (survivors.length !== owned.length) ownership.replaceAll(survivors);
|
|
231
|
+
const hw = ownership.harnessWindow();
|
|
232
|
+
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
233
|
+
// Prune per-tab page caches for tabs that no longer exist
|
|
234
|
+
for (const tid of pageCaches.keys()) {
|
|
235
|
+
if (!live.has(tid)) pageCaches.delete(tid);
|
|
236
|
+
}
|
|
190
237
|
return ok(tabs);
|
|
191
238
|
};
|
|
192
239
|
|
|
193
240
|
const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
|
|
194
241
|
const r = await session.switchTo(targetId);
|
|
195
242
|
if (!r.success) return r;
|
|
196
|
-
pageCache
|
|
243
|
+
// pageCache per-tab: no longer cleared — each tab retains its cache
|
|
197
244
|
// Best-effort: mark the tab title with a green circle so the user can
|
|
198
245
|
// see which tab the agent attached to. CSP or detached frames may
|
|
199
246
|
// block the eval; we don't surface that as a switchTab failure.
|
|
@@ -204,19 +251,46 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
204
251
|
};
|
|
205
252
|
|
|
206
253
|
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
|
|
207
|
-
|
|
254
|
+
// Verify the recorded harness window still exists. Querying the live
|
|
255
|
+
// target list also gives listTabs's reconciliation a chance to run.
|
|
256
|
+
const tabsResult = await listTabs(true);
|
|
257
|
+
if (!tabsResult.success) return tabsResult;
|
|
258
|
+
const live = new Set(tabsResult.data.map((t) => t.targetId));
|
|
259
|
+
const hw = ownership.harnessWindow();
|
|
260
|
+
|
|
261
|
+
const params: Record<string, unknown> = { url: "about:blank" };
|
|
262
|
+
if (hw && live.has(hw)) {
|
|
263
|
+
// Open as a child of the harness window's seed tab — Chrome places
|
|
264
|
+
// it in the same window, giving us visual grouping for free.
|
|
265
|
+
params["openerId"] = hw;
|
|
266
|
+
} else {
|
|
267
|
+
// Either fresh session or the harness window was closed by the user.
|
|
268
|
+
// Spawn a new dedicated window.
|
|
269
|
+
params["newWindow"] = true;
|
|
270
|
+
}
|
|
271
|
+
const created = await session.callBrowser("Target.createTarget", params);
|
|
208
272
|
if (!created.success) return created;
|
|
209
273
|
const c = created.data as { targetId: string };
|
|
274
|
+
|
|
275
|
+
if (params["newWindow"]) ownership.setHarnessWindow(c.targetId);
|
|
276
|
+
ownership.add(c.targetId);
|
|
277
|
+
|
|
210
278
|
const switched = await switchTab(c.targetId);
|
|
211
279
|
if (!switched.success) return switched;
|
|
212
280
|
if (url && url !== "about:blank") {
|
|
213
281
|
const nav = await session.call("Page.navigate", { url });
|
|
214
282
|
if (!nav.success) return nav;
|
|
215
|
-
pageCache = null;
|
|
216
283
|
}
|
|
217
284
|
return ok(c.targetId);
|
|
218
285
|
};
|
|
219
286
|
|
|
287
|
+
const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
|
|
288
|
+
const r = await session.callBrowser("Target.closeTarget", { targetId });
|
|
289
|
+
if (!r.success) return r;
|
|
290
|
+
ownership.remove(targetId);
|
|
291
|
+
return ok(undefined);
|
|
292
|
+
};
|
|
293
|
+
|
|
220
294
|
const status = (): DaemonStatus => ({
|
|
221
295
|
alive: transport.state() === "open" && session.current() !== null,
|
|
222
296
|
sessionId: session.current()?.sessionId ?? null,
|
|
@@ -229,7 +303,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
229
303
|
ensureAlive, status, start, stop,
|
|
230
304
|
evaluateJs, pageInfo,
|
|
231
305
|
takeDialog: () => session.takeDialog(),
|
|
232
|
-
listTabs, switchTab, newTab,
|
|
306
|
+
listTabs, switchTab, newTab, closeTab,
|
|
307
|
+
owns: (id: string) => ownership.has(id),
|
|
308
|
+
ownership: () => ownership,
|
|
233
309
|
current: () => session.current(),
|
|
234
310
|
session: () => session,
|
|
235
311
|
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,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { Markdown, Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import { type Result, ok } from "../util/result";
|
|
5
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
6
|
+
import { applyTruncation } from "../util/truncate";
|
|
7
|
+
import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer";
|
|
8
|
+
|
|
9
|
+
const ConsoleArgs = Type.Object({
|
|
10
|
+
levels: Type.Optional(
|
|
11
|
+
Type.Array(
|
|
12
|
+
Type.Union(
|
|
13
|
+
[
|
|
14
|
+
Type.Literal("log"),
|
|
15
|
+
Type.Literal("info"),
|
|
16
|
+
Type.Literal("warn"),
|
|
17
|
+
Type.Literal("error"),
|
|
18
|
+
Type.Literal("debug"),
|
|
19
|
+
],
|
|
20
|
+
),
|
|
21
|
+
{ description: 'Filter to these levels only, e.g. ["error","warn"]. Default: all levels.' },
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
textPattern: Type.Optional(
|
|
25
|
+
Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /TypeError/) for regex." }),
|
|
26
|
+
),
|
|
27
|
+
sinceSeq: Type.Optional(
|
|
28
|
+
Type.Integer({ minimum: 0, description: "Only records with seq strictly greater than this. Use the previous call's nextCursor to see only what's new." }),
|
|
29
|
+
),
|
|
30
|
+
sinceMs: Type.Optional(
|
|
31
|
+
Type.Integer({ minimum: 0, description: "Only records from the last N ms." }),
|
|
32
|
+
),
|
|
33
|
+
limit: Type.Optional(
|
|
34
|
+
Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const levelTag = (lvl: ConsoleLevel): string => {
|
|
39
|
+
switch (lvl) {
|
|
40
|
+
case "error": return "[error]";
|
|
41
|
+
case "warn": return "[warn] ";
|
|
42
|
+
case "info": return "[info] ";
|
|
43
|
+
case "debug": return "[debug]";
|
|
44
|
+
default: return "[log] ";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const formatLocation = (r: ConsoleRecord): string => {
|
|
49
|
+
if (r.url === undefined || r.url === "") return "";
|
|
50
|
+
const short = r.url.length > 60 ? "…" + r.url.slice(-59) : r.url;
|
|
51
|
+
return r.lineNumber !== undefined ? `${short}:${r.lineNumber}` : short;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const renderConsoleMarkdown = (
|
|
55
|
+
records: ReadonlyArray<ConsoleRecord>,
|
|
56
|
+
opts: { total: number; bufferOverflowed: boolean; expanded: boolean; nextCursor: number | undefined },
|
|
57
|
+
): string => {
|
|
58
|
+
if (records.length === 0) {
|
|
59
|
+
if (opts.bufferOverflowed) return "_No matching console records. (Buffer overflowed since last drain — some events were dropped.)_";
|
|
60
|
+
return "_No matching console records captured yet._";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const counts = records.reduce<Record<ConsoleLevel, number>>(
|
|
64
|
+
(acc, r) => { acc[r.level]++; return acc; },
|
|
65
|
+
{ log: 0, info: 0, warn: 0, error: 0, debug: 0 },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const summaryParts: string[] = [];
|
|
69
|
+
if (counts.error > 0) summaryParts.push(`${counts.error} error${counts.error === 1 ? "" : "s"}`);
|
|
70
|
+
if (counts.warn > 0) summaryParts.push(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`);
|
|
71
|
+
if (counts.info > 0) summaryParts.push(`${counts.info} info`);
|
|
72
|
+
if (counts.log > 0) summaryParts.push(`${counts.log} log${counts.log === 1 ? "" : "s"}`);
|
|
73
|
+
if (counts.debug > 0) summaryParts.push(`${counts.debug} debug`);
|
|
74
|
+
|
|
75
|
+
const headerLines: string[] = [`**${summaryParts.join(" · ")}**`];
|
|
76
|
+
if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older entries dropped");
|
|
77
|
+
if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
|
|
78
|
+
|
|
79
|
+
const renderRow = (r: ConsoleRecord, compact: boolean): string => {
|
|
80
|
+
const tag = levelTag(r.level);
|
|
81
|
+
const loc = formatLocation(r);
|
|
82
|
+
const maxText = compact ? 80 : 200;
|
|
83
|
+
const text = r.text.length > maxText ? r.text.slice(0, maxText - 1) + "…" : r.text;
|
|
84
|
+
const locSuffix = loc !== "" ? ` _at ${loc}_` : "";
|
|
85
|
+
const main = `\`${tag}\` ${text}${locSuffix}`;
|
|
86
|
+
if (!compact && r.stackTrace !== undefined) return `${main}\n\`\`\`\n${r.stackTrace}\n\`\`\``;
|
|
87
|
+
return main;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const visible = !opts.expanded && records.length > 5 ? records.slice(-5) : records;
|
|
91
|
+
const rows = visible.map((r) => renderRow(r, !opts.expanded)).join(opts.expanded ? "\n\n" : "\n");
|
|
92
|
+
|
|
93
|
+
const more = !opts.expanded && records.length > 5 ? `\n … ${records.length - 5} earlier (expand to see all)` : "";
|
|
94
|
+
const cursor = opts.nextCursor !== undefined ? `\n\n_nextCursor=${opts.nextCursor}_` : "";
|
|
95
|
+
|
|
96
|
+
return `${headerLines.join("\n")}\n\n${rows}${more}${cursor}`;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type ConsoleDetails = {
|
|
100
|
+
total: number;
|
|
101
|
+
returned: number;
|
|
102
|
+
bufferOverflowed: boolean;
|
|
103
|
+
truncated: boolean;
|
|
104
|
+
fullOutputPath?: string;
|
|
105
|
+
records: ReadonlyArray<ConsoleRecord>;
|
|
106
|
+
nextCursor?: number;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const consoleTool = defineBrowserTool({
|
|
110
|
+
name: "browser_console",
|
|
111
|
+
label: "Browser Console",
|
|
112
|
+
description:
|
|
113
|
+
"Read JS errors and console messages from the active tab. Diagnostic — use when an action looks broken or silent.",
|
|
114
|
+
promptSnippet: "Read JS errors / console output on the current tab",
|
|
115
|
+
promptGuidelines: [
|
|
116
|
+
"Use after a click, submit, or navigate produced no visible change — errors often explain it.",
|
|
117
|
+
"Pass `sinceSeq` from the previous call's `nextCursor` to see only new messages after an action.",
|
|
118
|
+
"Buffer is reset on tab switch (capacity 500 records). bufferOverflowed:true means older entries were dropped.",
|
|
119
|
+
"Don't use as a default observation tool — prefer browser_snapshot for page structure.",
|
|
120
|
+
],
|
|
121
|
+
parameters: ConsoleArgs,
|
|
122
|
+
|
|
123
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
124
|
+
const session = client.session();
|
|
125
|
+
const drained = session.drainConsoleBuffer({
|
|
126
|
+
...(args.levels !== undefined ? { levels: args.levels } : {}),
|
|
127
|
+
...(args.textPattern !== undefined ? { textPattern: args.textPattern } : {}),
|
|
128
|
+
...(args.sinceSeq !== undefined ? { sinceSeq: args.sinceSeq } : {}),
|
|
129
|
+
...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
|
|
130
|
+
limit: args.limit ?? 50,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const last = drained.records.at(-1);
|
|
134
|
+
const nextCursor = last?.seq;
|
|
135
|
+
|
|
136
|
+
const text = renderConsoleMarkdown(drained.records, {
|
|
137
|
+
total: drained.total,
|
|
138
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
139
|
+
expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans
|
|
140
|
+
nextCursor,
|
|
141
|
+
});
|
|
142
|
+
const trunc = await applyTruncation(text, "console");
|
|
143
|
+
|
|
144
|
+
const details: ConsoleDetails = {
|
|
145
|
+
total: drained.total,
|
|
146
|
+
returned: drained.records.length,
|
|
147
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
148
|
+
truncated: trunc.wasTruncated,
|
|
149
|
+
records: drained.records,
|
|
150
|
+
...(nextCursor !== undefined ? { nextCursor } : {}),
|
|
151
|
+
};
|
|
152
|
+
if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
|
|
153
|
+
|
|
154
|
+
return ok({ text: trunc.text, details });
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
renderResult(result, expanded, theme) {
|
|
158
|
+
const details = result.details as ConsoleDetails | undefined;
|
|
159
|
+
if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0);
|
|
160
|
+
|
|
161
|
+
const md = renderConsoleMarkdown(details.records, {
|
|
162
|
+
total: details.total,
|
|
163
|
+
bufferOverflowed: details.bufferOverflowed,
|
|
164
|
+
expanded,
|
|
165
|
+
nextCursor: details.nextCursor,
|
|
166
|
+
});
|
|
167
|
+
const tail = expanded
|
|
168
|
+
? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
|
|
169
|
+
: `\n\n${keyHint("app.tools.expand", "to expand")}`;
|
|
170
|
+
|
|
171
|
+
return new Markdown(md + tail, 0, 0, getMarkdownTheme());
|
|
172
|
+
},
|
|
173
|
+
});
|
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,
|