pi-browser-harness 0.12.0 → 0.13.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 +36 -0
- package/README.md +86 -0
- package/package.json +12 -6
- package/skills/pi-browser-harness/SKILL.md +57 -2
- package/src/cdp/commands.ts +200 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +57 -25
- package/src/cdp/events.ts +8 -3
- package/src/cdp/network-buffer.ts +38 -32
- package/src/cdp/ownership.ts +22 -4
- package/src/cdp/record-store.ts +2 -2
- package/src/cdp/session.ts +320 -217
- package/src/cdp/target-factory.ts +34 -15
- package/src/cdp/types.ts +17 -1
- package/src/client.ts +76 -43
- package/src/daemon/bridge.ts +110 -349
- package/src/daemon/connection.ts +323 -0
- package/src/daemon/index.ts +77 -48
- package/src/daemon/lines.ts +40 -0
- package/src/daemon/main.ts +12 -0
- package/src/daemon/protocol.ts +55 -25
- package/src/daemon/server.ts +100 -63
- package/src/daemon/socket.ts +10 -0
- package/src/daemon/spawn.ts +21 -22
- package/src/daemon/transport.ts +80 -49
- package/src/domains/ax-tree.ts +8 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -2
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -64
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +13 -9
- package/src/domains/form-batch.ts +13 -10
- package/src/domains/form.ts +78 -107
- package/src/domains/headers.ts +2 -2
- package/src/domains/history.ts +4 -4
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +65 -44
- package/src/domains/keyboard.ts +14 -11
- package/src/domains/navigate.ts +102 -66
- package/src/domains/network.ts +55 -95
- package/src/domains/page/fill.page.ts +139 -0
- package/src/domains/page/form.page.ts +61 -0
- package/src/domains/page/inject.ts +37 -0
- package/src/domains/page/keyboard.page.ts +16 -0
- package/src/domains/page/live-value.page.ts +10 -0
- package/src/domains/page/read-page.page.ts +51 -0
- package/src/domains/page/serp.page.ts +39 -0
- package/src/domains/page.ts +18 -51
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -53
- package/src/domains/ref-resolve.ts +43 -29
- package/src/domains/ref-signature.ts +11 -0
- package/src/domains/render.ts +12 -23
- package/src/domains/screenshot-capture.ts +4 -2
- package/src/domains/screenshot.ts +13 -10
- package/src/domains/scroll.ts +4 -4
- package/src/domains/search/google-serp.ts +1 -1
- package/src/domains/search/web-search.ts +38 -33
- package/src/domains/snapshot.ts +71 -73
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +18 -69
- package/src/domains/target.ts +1 -1
- package/src/index.ts +90 -33
- package/src/profile/bind.ts +45 -14
- package/src/profile/browser-process.ts +50 -12
- package/src/profile/command.ts +20 -12
- package/src/profile/launch.ts +13 -9
- package/src/profile/list.ts +9 -13
- package/src/profile/store.ts +2 -1
- package/src/prompt.ts +32 -7
- package/src/registry.ts +65 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +2 -1
- package/src/util/debug.ts +7 -1
- package/src/util/guards.ts +3 -0
- package/src/util/mutex.ts +1 -1
- package/src/util/paths.ts +3 -2
- package/src/util/result.ts +8 -0
- package/src/util/sharp-shim.ts +18 -11
- package/src/util/sleep.ts +18 -0
- package/src/util/tool.ts +163 -114
- package/src/util/truncate.ts +1 -1
- package/tsconfig.json +2 -1
- package/src/domains/fill-engine.ts +0 -80
package/src/domains/js.ts
CHANGED
|
@@ -4,28 +4,53 @@ import { isPathWithin } from "../util/paths";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
|
-
import { Markdown
|
|
7
|
+
import { Markdown } from "@mariozechner/pi-tui";
|
|
8
8
|
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
9
9
|
import { type Result, err, ok } from "../util/result";
|
|
10
10
|
import { formatBytes } from "../util/truncate";
|
|
11
|
-
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
11
|
+
import { type DetailsOf, defineBrowserTool, isJsonObject, type ToolErr, type ToolOk } from "../util/tool";
|
|
12
12
|
import { evalJs } from "./cdp-call";
|
|
13
13
|
import { applyTruncation } from "../util/truncate";
|
|
14
|
-
import {
|
|
14
|
+
import { isRecord } from "../util/guards";
|
|
15
15
|
|
|
16
16
|
const COMPACT_PREVIEW_BYTES = 120;
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
type AsyncFunctionConstructor = new (...args: ReadonlyArray<string>) => (...args: ReadonlyArray<unknown>) => Promise<unknown>;
|
|
19
|
+
|
|
20
|
+
// The AsyncFunction constructor is the documented way to compile user source. It is only reachable through
|
|
21
|
+
// Object.getPrototypeOf, which returns `any`; this is the one sanctioned cast (budgeted in boundary-allowlist.ts)
|
|
22
|
+
// because a plain `unknown` would lose the constructor signature and nothing can narrow back to it.
|
|
23
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-type-assertion -- the constructor is only reachable through an `any`-typed prototype lookup */
|
|
24
|
+
const AsyncFunction = Object.getPrototypeOf(async () => { /* only its prototype is wanted */ }).constructor as
|
|
25
|
+
AsyncFunctionConstructor;
|
|
26
|
+
/* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-type-assertion */
|
|
21
27
|
|
|
22
28
|
const requireFromHere = createRequire(import.meta.url);
|
|
23
29
|
|
|
30
|
+
// Pretty-prints for the expanded renderer; a truncated (unparseable) payload is shown as-is.
|
|
31
|
+
const prettyJson = (text: string): string => {
|
|
32
|
+
try {
|
|
33
|
+
const parsed: unknown = JSON.parse(text);
|
|
34
|
+
return JSON.stringify(parsed, null, 2);
|
|
35
|
+
} catch {
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
24
40
|
const ExecuteJsArgs = Type.Object({
|
|
25
41
|
expression: Type.String({ description: "JavaScript expression. `return X` is auto-wrapped in an IIFE." }),
|
|
26
42
|
targetId: Type.Optional(Type.String({ description: "Optional iframe targetId; default = current page." })),
|
|
27
43
|
});
|
|
28
44
|
|
|
45
|
+
const ExecuteJsDetailsSchema = Type.Object({
|
|
46
|
+
valueLength: Type.Number(),
|
|
47
|
+
preview: Type.String(),
|
|
48
|
+
isJson: Type.Boolean(),
|
|
49
|
+
fullOutputPath: Type.Optional(Type.String()),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type ExecuteJsDetails = DetailsOf<typeof ExecuteJsDetailsSchema>;
|
|
53
|
+
|
|
29
54
|
export const executeJsTool = defineBrowserTool({
|
|
30
55
|
name: "browser_execute_js",
|
|
31
56
|
label: "Browser Execute JS",
|
|
@@ -40,46 +65,35 @@ export const executeJsTool = defineBrowserTool({
|
|
|
40
65
|
"Result must be JSON-serializable (Runtime.evaluate returnByValue=true).",
|
|
41
66
|
],
|
|
42
67
|
parameters: ExecuteJsArgs,
|
|
68
|
+
details: ExecuteJsDetailsSchema,
|
|
43
69
|
concurrency: "parallel",
|
|
44
|
-
async handler(args, { client }): Promise<Result<ToolOk
|
|
70
|
+
async handler(args, { client }): Promise<Result<ToolOk<ExecuteJsDetails>, ToolErr>> {
|
|
45
71
|
const r = await evalJs(client, args.expression, args.targetId);
|
|
46
72
|
if (!r.success) return r;
|
|
47
73
|
const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data);
|
|
74
|
+
// The full value lives in the text (and in fullOutputPath when truncated); details carry only what the
|
|
75
|
+
// renderer needs so a large result is not persisted twice.
|
|
76
|
+
const isJson = r.data !== undefined;
|
|
48
77
|
const truncated = await applyTruncation(valueStr, "js");
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
const parsed: unknown = JSON.parse(valueStr);
|
|
52
|
-
pretty = JSON.stringify(parsed, null, 2);
|
|
53
|
-
} catch {
|
|
54
|
-
pretty = undefined;
|
|
55
|
-
}
|
|
78
|
+
const preview = valueStr.length > COMPACT_PREVIEW_BYTES ? valueStr.slice(0, COMPACT_PREVIEW_BYTES) + "…" : valueStr;
|
|
56
79
|
return ok({
|
|
57
80
|
text: truncated.text,
|
|
58
81
|
details: {
|
|
59
82
|
valueLength: valueStr.length,
|
|
60
|
-
|
|
61
|
-
|
|
83
|
+
preview,
|
|
84
|
+
isJson,
|
|
62
85
|
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
63
86
|
},
|
|
64
87
|
});
|
|
65
88
|
},
|
|
66
89
|
|
|
67
|
-
renderResult(result, expanded
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
full: asString(raw["full"]),
|
|
73
|
-
pretty: asString(raw["pretty"]),
|
|
74
|
-
fullOutputPath: asString(raw["fullOutputPath"]),
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const len = details.valueLength ?? 0;
|
|
78
|
-
const full = details.full ?? "";
|
|
79
|
-
const isJson = details.pretty !== undefined;
|
|
90
|
+
renderResult(result, expanded) {
|
|
91
|
+
const { valueLength: len, preview, isJson, fullOutputPath } = result.details;
|
|
92
|
+
const content = result.content[0];
|
|
93
|
+
const fullText = content?.type === "text" ? content.text : preview;
|
|
94
|
+
const body = isJson ? prettyJson(fullText) : fullText;
|
|
80
95
|
|
|
81
96
|
if (!expanded) {
|
|
82
|
-
const preview = full.length > COMPACT_PREVIEW_BYTES ? full.slice(0, COMPACT_PREVIEW_BYTES) + "…" : full;
|
|
83
97
|
const md = [
|
|
84
98
|
`**${formatBytes(len)}** ${isJson ? "JSON" : "value"}`,
|
|
85
99
|
"```",
|
|
@@ -90,10 +104,9 @@ export const executeJsTool = defineBrowserTool({
|
|
|
90
104
|
return new Markdown(md, 0, 0, getMarkdownTheme());
|
|
91
105
|
}
|
|
92
106
|
|
|
93
|
-
const body = details.pretty ?? full;
|
|
94
107
|
const fence = isJson ? "```json" : "```";
|
|
95
|
-
const tail =
|
|
96
|
-
? `\n\nFull value at \`${
|
|
108
|
+
const tail = fullOutputPath !== undefined
|
|
109
|
+
? `\n\nFull value at \`${fullOutputPath}\` · ${keyHint("app.tools.expand", "to collapse")}`
|
|
97
110
|
: `\n\n${keyHint("app.tools.expand", "to collapse")}`;
|
|
98
111
|
const md = [`**${formatBytes(len)}** ${isJson ? "JSON" : "value"}`, fence, body, "```", tail].join("\n");
|
|
99
112
|
return new Markdown(md, 0, 0, getMarkdownTheme());
|
|
@@ -144,11 +157,11 @@ export const runScriptTool = defineBrowserTool({
|
|
|
144
157
|
concurrency: "parallel",
|
|
145
158
|
async handler(args, { client, signal, onUpdate, extensionCtx }): Promise<Result<ToolOk, ToolErr>> {
|
|
146
159
|
if (!isAbsolute(args.path)) {
|
|
147
|
-
return err({ kind: "
|
|
160
|
+
return err({ kind: "invalid_args", message: "Script path must be absolute" });
|
|
148
161
|
}
|
|
149
162
|
if (!isPathAllowed(args.path)) {
|
|
150
163
|
return err({
|
|
151
|
-
kind: "
|
|
164
|
+
kind: "invalid_args",
|
|
152
165
|
message: `Script path outside allowed directories (allowed: ${allowedRoots().join(", ")})`,
|
|
153
166
|
});
|
|
154
167
|
}
|
|
@@ -161,9 +174,9 @@ export const runScriptTool = defineBrowserTool({
|
|
|
161
174
|
message: `Failed to read script: ${e instanceof Error ? e.message : String(e)}`,
|
|
162
175
|
});
|
|
163
176
|
}
|
|
164
|
-
if (source.length === 0) return err({ kind: "
|
|
177
|
+
if (source.length === 0) return err({ kind: "invalid_args", message: "Script is empty" });
|
|
165
178
|
if (source.length > MAX_SOURCE_BYTES) {
|
|
166
|
-
return err({ kind: "
|
|
179
|
+
return err({ kind: "invalid_args", message: `Script exceeds ${MAX_SOURCE_BYTES}B size cap` });
|
|
167
180
|
}
|
|
168
181
|
|
|
169
182
|
let executeFn: (...a: ReadonlyArray<unknown>) => Promise<unknown>;
|
|
@@ -175,16 +188,16 @@ export const runScriptTool = defineBrowserTool({
|
|
|
175
188
|
);
|
|
176
189
|
} catch (e) {
|
|
177
190
|
return err({
|
|
178
|
-
kind: "
|
|
191
|
+
kind: "invalid_args",
|
|
179
192
|
message: `Syntax error: ${e instanceof Error ? e.message : String(e)}`,
|
|
180
193
|
});
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
const timeoutMs = args.timeoutMs ?? 60_000;
|
|
184
197
|
const ac = new AbortController();
|
|
185
|
-
const onAbort = (): void => ac.abort();
|
|
198
|
+
const onAbort = (): void => { ac.abort(); };
|
|
186
199
|
if (signal !== undefined) signal.addEventListener("abort", onAbort, { once: true });
|
|
187
|
-
const timeoutTimer = setTimeout(
|
|
200
|
+
const timeoutTimer = setTimeout(onAbort, timeoutMs);
|
|
188
201
|
|
|
189
202
|
try {
|
|
190
203
|
const scriptPromise = executeFn(
|
|
@@ -200,13 +213,17 @@ export const runScriptTool = defineBrowserTool({
|
|
|
200
213
|
if (!isRecord(first)) return;
|
|
201
214
|
const txt = first["text"];
|
|
202
215
|
if (typeof txt !== "string") return;
|
|
203
|
-
try {
|
|
216
|
+
try {
|
|
217
|
+
onUpdate({ text: txt });
|
|
218
|
+
} catch {
|
|
219
|
+
// Progress is best-effort; a renderer failure must not abort the script.
|
|
220
|
+
}
|
|
204
221
|
},
|
|
205
|
-
extensionCtx
|
|
222
|
+
extensionCtx,
|
|
206
223
|
console, fetch, JSON, Buffer, setTimeout, clearTimeout,
|
|
207
224
|
);
|
|
208
225
|
const abortPromise = new Promise<never>((_, reject) => {
|
|
209
|
-
ac.signal.addEventListener("abort", () => reject(new Error("script aborted (timeout or cancellation)")), { once: true });
|
|
226
|
+
ac.signal.addEventListener("abort", () => { reject(new Error("script aborted (timeout or cancellation)")); }, { once: true });
|
|
210
227
|
});
|
|
211
228
|
const result = await Promise.race([scriptPromise, abortPromise]);
|
|
212
229
|
clearTimeout(timeoutTimer);
|
|
@@ -224,7 +241,11 @@ export const runScriptTool = defineBrowserTool({
|
|
|
224
241
|
}
|
|
225
242
|
const textOut = content.map((c) => c.text).join("\n");
|
|
226
243
|
const details = result["details"];
|
|
227
|
-
|
|
244
|
+
if (details === undefined) return ok({ text: textOut });
|
|
245
|
+
if (!isJsonObject(details)) {
|
|
246
|
+
return err({ kind: "invalid_state", message: "Script details must be a plain JSON object (no Map, function or undefined values)" });
|
|
247
|
+
}
|
|
248
|
+
return ok({ text: textOut, details });
|
|
228
249
|
} catch (e) {
|
|
229
250
|
clearTimeout(timeoutTimer);
|
|
230
251
|
if (signal !== undefined) signal.removeEventListener("abort", onAbort);
|
package/src/domains/keyboard.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import type { ParamsOf } from "../cdp/commands";
|
|
2
3
|
import { safeJs } from "../util/js-template";
|
|
3
4
|
import { type Result, err, ok } from "../util/result";
|
|
4
5
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
5
6
|
import { cdpCall, evalJs } from "./cdp-call";
|
|
6
|
-
import {
|
|
7
|
+
import { runOnElement } from "./element-call";
|
|
8
|
+
import { elementFunctionSource } from "./page/inject";
|
|
9
|
+
import { dispatchKeyEvent, type KeyEventSpec } from "./page/keyboard.page";
|
|
10
|
+
|
|
11
|
+
const DISPATCH_KEY_FN = elementFunctionSource(dispatchKeyEvent);
|
|
7
12
|
|
|
8
13
|
const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
|
|
9
14
|
Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32,
|
|
10
15
|
ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
|
|
11
16
|
Home: 36, End: 35, PageUp: 33, PageDown: 34,
|
|
17
|
+
Insert: 45, Shift: 16, Control: 17, Alt: 18, Meta: 91, CapsLock: 20, ContextMenu: 93,
|
|
18
|
+
F1: 112, F2: 113, F3: 114, F4: 115, F5: 116, F6: 117,
|
|
19
|
+
F7: 118, F8: 119, F9: 120, F10: 121, F11: 122, F12: 123,
|
|
12
20
|
};
|
|
13
21
|
|
|
14
22
|
const virtualKeyCode = (key: string): number =>
|
|
@@ -92,7 +100,7 @@ export const pressKeyTool = defineBrowserTool({
|
|
|
92
100
|
const code = virtualKeyCode(k);
|
|
93
101
|
const modifiers = args.modifiers ?? 0;
|
|
94
102
|
const isChar = k.length === 1;
|
|
95
|
-
const downParams:
|
|
103
|
+
const downParams: ParamsOf<"Input.dispatchKeyEvent"> = {
|
|
96
104
|
type: "keyDown",
|
|
97
105
|
key: k,
|
|
98
106
|
code: k,
|
|
@@ -147,14 +155,9 @@ export const dispatchKeyTool = defineBrowserTool({
|
|
|
147
155
|
// Legacy handlers branch on e.keyCode/e.which rather than e.key, so populate both.
|
|
148
156
|
const code = virtualKeyCode(args.key);
|
|
149
157
|
if (args.ref !== undefined) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const r = await
|
|
153
|
-
objectId: objectId.data,
|
|
154
|
-
functionDeclaration: `function (type, key, keyCode) { this.dispatchEvent(new KeyboardEvent(type, { key, keyCode, which: keyCode, bubbles: true, cancelable: true })); return 1; }`,
|
|
155
|
-
arguments: [{ value: eventType }, { value: args.key }, { value: code }],
|
|
156
|
-
returnByValue: true,
|
|
157
|
-
});
|
|
158
|
+
// Through runOnElement so the resolved RemoteObject is released after the call.
|
|
159
|
+
const spec: KeyEventSpec = { type: eventType, key: args.key, code };
|
|
160
|
+
const r = await runOnElement(client, { ref: args.ref, functionDeclaration: DISPATCH_KEY_FN, args: [spec] });
|
|
158
161
|
if (!r.success) return r;
|
|
159
162
|
return ok({
|
|
160
163
|
text: `Dispatched ${eventType}('${args.key}') on ${target}`,
|
|
@@ -162,7 +165,7 @@ export const dispatchKeyTool = defineBrowserTool({
|
|
|
162
165
|
});
|
|
163
166
|
}
|
|
164
167
|
if (args.selector === undefined) {
|
|
165
|
-
return err({ kind: "
|
|
168
|
+
return err({ kind: "invalid_args", message: "Provide either `ref` or `selector`." });
|
|
166
169
|
}
|
|
167
170
|
const expr = safeJs`
|
|
168
171
|
(() => {
|
package/src/domains/navigate.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import type { BrowserClient } from "../client";
|
|
3
4
|
import { type Result, err, ok } from "../util/result";
|
|
4
5
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
5
6
|
import { applyTruncation } from "../util/truncate";
|
|
6
7
|
import { safeJs } from "../util/js-template";
|
|
7
|
-
import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
|
|
8
|
-
import { cdpCall, cdpCallBrowser, cdpCallOnTarget, evalJs } from "./cdp-call";
|
|
8
|
+
import { borrowSession, ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
|
|
9
|
+
import { cdpCall, cdpCallBrowser, cdpCallOnTarget, evalJs, fromClient } from "./cdp-call";
|
|
10
|
+
import { pollUntil, READY_STATE_COMPLETE } from "./poll";
|
|
9
11
|
|
|
10
12
|
const NavigateArgs = Type.Object({
|
|
11
13
|
url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
|
|
@@ -29,12 +31,12 @@ export const navigateTool = defineBrowserTool({
|
|
|
29
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
|
-
const tabs = await client.listTabs(false);
|
|
33
|
-
if (!tabs.success) return
|
|
34
|
+
const tabs = fromClient(await client.listTabs(false), "Target.getTargets");
|
|
35
|
+
if (!tabs.success) return tabs;
|
|
34
36
|
let outcome: NavOutcome;
|
|
35
37
|
if (tabs.data.length === 0) {
|
|
36
|
-
const created = await client.newTab(args.url);
|
|
37
|
-
if (!created.success) return
|
|
38
|
+
const created = fromClient(await client.newTab(args.url), "Target.createTarget");
|
|
39
|
+
if (!created.success) return created;
|
|
38
40
|
outcome = { kind: "new_tab_created", targetId: created.data, reason: "no_tabs" };
|
|
39
41
|
} else {
|
|
40
42
|
const nav = await cdpCall(client, "Page.navigate", { url: args.url });
|
|
@@ -43,9 +45,9 @@ export const navigateTool = defineBrowserTool({
|
|
|
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 };
|
|
45
47
|
}
|
|
46
|
-
const info = await client.pageInfo();
|
|
47
|
-
if (!info.success) return
|
|
48
|
-
if ("dialog"
|
|
48
|
+
const info = fromClient(await client.pageInfo(), "pageInfo");
|
|
49
|
+
if (!info.success) return info;
|
|
50
|
+
if (info.data.kind === "dialog") {
|
|
49
51
|
return ok({
|
|
50
52
|
text: `Navigated to ${args.url}\nTarget: ${outcome.targetId}\n⚠️ Dialog open: ${info.data.dialog.type} — "${info.data.dialog.message}"\nUse browser_handle_dialog.`,
|
|
51
53
|
details: { outcome, dialog: info.data.dialog },
|
|
@@ -68,6 +70,76 @@ type TabResult = {
|
|
|
68
70
|
readonly targetId: string;
|
|
69
71
|
readonly ok: boolean;
|
|
70
72
|
readonly error?: string;
|
|
73
|
+
readonly note?: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// The marker is applied once the document has loaded; applying it right after Page.navigate would mark the
|
|
77
|
+
// outgoing document and be overwritten by the incoming one.
|
|
78
|
+
const TITLE_MARK_TIMEOUT_MS = 10_000;
|
|
79
|
+
|
|
80
|
+
// Opens one tab per url in a single harness window. All tabs must land in ONE window: a parallel newWindow:true
|
|
81
|
+
// per url would scatter them across N windows, so the first url seeds the window and the rest open as its children.
|
|
82
|
+
const createTabs = async (client: BrowserClient, urls: ReadonlyArray<string>): Promise<Result<ReadonlyArray<TabResult>, ToolErr>> => {
|
|
83
|
+
const url0 = urls[0];
|
|
84
|
+
if (url0 === undefined) return err({ kind: "invalid_args", message: "no URLs provided" });
|
|
85
|
+
const window = await ensureHarnessWindow(client);
|
|
86
|
+
if (!window.success) return fromClient(window, "Target.createTarget");
|
|
87
|
+
const seededWindow = window.data.targetId;
|
|
88
|
+
const seedResult: TabResult | undefined = window.data.freshlyCreated
|
|
89
|
+
? { url: url0, targetId: seededWindow, ok: true }
|
|
90
|
+
: undefined;
|
|
91
|
+
const created = await Promise.all(urls.map(async (url, i): Promise<TabResult> => {
|
|
92
|
+
if (i === 0 && seedResult) return seedResult;
|
|
93
|
+
const r = await openHarnessTab(client, seededWindow);
|
|
94
|
+
if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
|
|
95
|
+
return { url, targetId: r.data, ok: true };
|
|
96
|
+
}));
|
|
97
|
+
return ok(created);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Cosmetic, and polled per tab in parallel: a slow interval keeps N tabs from hammering the daemon socket.
|
|
101
|
+
const TITLE_MARK_POLL_MS = 250;
|
|
102
|
+
|
|
103
|
+
const markTitle = async (client: BrowserClient, sessionId: string, signal: AbortSignal | undefined): Promise<Result<void, ToolErr>> => {
|
|
104
|
+
const loaded = await pollUntil(client, READY_STATE_COMPLETE, { timeoutMs: TITLE_MARK_TIMEOUT_MS, intervalMs: TITLE_MARK_POLL_MS, signal, sessionId, what: "page load" });
|
|
105
|
+
if (!loaded.success) return loaded;
|
|
106
|
+
const marked = await evalJs(client, safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`, sessionId);
|
|
107
|
+
if (!marked.success) return marked;
|
|
108
|
+
return ok(undefined);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Borrows a session on a created tab, navigates it, and marks its title after load. A marker failure is a note, not a failure.
|
|
112
|
+
// The borrowed session is released in every path so an untracked attach never leaks past this call.
|
|
113
|
+
const navigateTab = async (client: BrowserClient, tab: TabResult, signal: AbortSignal | undefined): Promise<TabResult> => {
|
|
114
|
+
const borrowed = fromClient(await borrowSession(client, tab.targetId), "Target.attachToTarget");
|
|
115
|
+
if (!borrowed.success) return { ...tab, ok: false, error: borrowed.error.message };
|
|
116
|
+
const sid = borrowed.data.sessionId;
|
|
117
|
+
try {
|
|
118
|
+
const enabled = await cdpCallOnTarget(client, "Page.enable", {}, sid);
|
|
119
|
+
if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message };
|
|
120
|
+
const nav = await cdpCallOnTarget(client, "Page.navigate", { url: tab.url }, sid);
|
|
121
|
+
if (!nav.success) return { ...tab, ok: false, error: nav.error.message };
|
|
122
|
+
const marked = await markTitle(client, sid, signal);
|
|
123
|
+
return marked.success ? tab : { ...tab, note: "still loading" };
|
|
124
|
+
} finally {
|
|
125
|
+
await borrowed.data.release();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const renderOutcomes = (all: ReadonlyArray<TabResult>, total: number): string => {
|
|
130
|
+
const okTabs = all.filter((r) => r.ok);
|
|
131
|
+
const failTabs = all.filter((r) => !r.ok);
|
|
132
|
+
const lines: string[] = [];
|
|
133
|
+
if (okTabs.length) {
|
|
134
|
+
lines.push(
|
|
135
|
+
`✅ ${okTabs.length}/${total} tabs opened:`,
|
|
136
|
+
...okTabs.map((t, i) => ` [${i}] ${t.url} → ${t.targetId}${t.note !== undefined ? ` (${t.note})` : ""}`),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (failTabs.length) {
|
|
140
|
+
lines.push(`❌ ${failTabs.length}/${total} failed:`, ...failTabs.map((t) => ` ${t.url}: ${t.error ?? "unknown"}`));
|
|
141
|
+
}
|
|
142
|
+
return lines.join("\n");
|
|
71
143
|
};
|
|
72
144
|
|
|
73
145
|
export const openUrlsTool = defineBrowserTool({
|
|
@@ -82,77 +154,41 @@ export const openUrlsTool = defineBrowserTool({
|
|
|
82
154
|
parameters: OpenUrlsArgs,
|
|
83
155
|
concurrency: "serialized",
|
|
84
156
|
renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
|
|
85
|
-
async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
|
|
157
|
+
async handler(args, { client, onUpdate, signal }): Promise<Result<ToolOk, ToolErr>> {
|
|
86
158
|
const total = args.urls.length;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const window = await ensureHarnessWindow(client);
|
|
91
|
-
if (!window.success) return err({ kind: "cdp_error", message: window.error.message });
|
|
92
|
-
const seededWindow = window.data.targetId;
|
|
93
|
-
const seedResult: TabResult | undefined = window.data.freshlyCreated
|
|
94
|
-
? { url: url0, targetId: seededWindow, ok: true }
|
|
95
|
-
: undefined;
|
|
96
|
-
const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => {
|
|
97
|
-
if (i === 0 && seedResult) return seedResult;
|
|
98
|
-
const r = await openHarnessTab(client, seededWindow);
|
|
99
|
-
if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
|
|
100
|
-
return { url, targetId: r.data, ok: true };
|
|
101
|
-
}));
|
|
159
|
+
const created = await createTabs(client, args.urls);
|
|
160
|
+
if (!created.success) return created;
|
|
161
|
+
const toNavigate = created.data.filter((t) => t.ok);
|
|
102
162
|
let completed = 0;
|
|
103
163
|
const settled = await Promise.all(
|
|
104
|
-
|
|
164
|
+
toNavigate.map(async (tab): Promise<TabResult> => {
|
|
105
165
|
try {
|
|
106
|
-
|
|
107
|
-
if (!attached.success) return { ...tab, ok: false, error: attached.error.message };
|
|
108
|
-
const sid = attached.data;
|
|
109
|
-
const enabled = await cdpCallOnTarget(client, "Page.enable", {}, sid);
|
|
110
|
-
if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message };
|
|
111
|
-
const nav = await cdpCallOnTarget(client, "Page.navigate", { url: tab.url }, sid);
|
|
112
|
-
if (!nav.success) return { ...tab, ok: false, error: nav.error.message };
|
|
113
|
-
evalJs(
|
|
114
|
-
client,
|
|
115
|
-
safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
|
|
116
|
-
sid
|
|
117
|
-
).catch(() => {});
|
|
118
|
-
return tab;
|
|
166
|
+
return await navigateTab(client, tab, signal);
|
|
119
167
|
} finally {
|
|
120
168
|
completed++;
|
|
121
169
|
// onUpdate must never escape: a renderer crash would otherwise tear down the tool call.
|
|
122
170
|
try {
|
|
123
|
-
onUpdate({ text: `Opening URLs… ${completed}/${
|
|
124
|
-
} catch {
|
|
171
|
+
onUpdate({ text: `Opening URLs… ${completed}/${toNavigate.length} navigated` });
|
|
172
|
+
} catch {
|
|
173
|
+
// Progress is best-effort; the navigation outcome is what the tool reports.
|
|
174
|
+
}
|
|
125
175
|
}
|
|
126
176
|
}),
|
|
127
177
|
);
|
|
128
|
-
const
|
|
129
|
-
const all: ReadonlyArray<TabResult> = [...settled, ...failures];
|
|
178
|
+
const all: ReadonlyArray<TabResult> = [...settled, ...created.data.filter((t) => !t.ok)];
|
|
130
179
|
const okTabs = all.filter((r) => r.ok);
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
if (last) {
|
|
135
|
-
await cdpCallBrowser(client, "Target.activateTarget", { targetId: last.targetId });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
const lines: string[] = [];
|
|
139
|
-
if (okTabs.length) {
|
|
140
|
-
lines.push(`✅ ${okTabs.length}/${total} tabs opened:`, ...okTabs.map((t, i) => ` [${i}] ${t.url} → ${t.targetId}`));
|
|
180
|
+
const last = okTabs[okTabs.length - 1];
|
|
181
|
+
if (last !== undefined) {
|
|
182
|
+
await cdpCallBrowser(client, "Target.activateTarget", { targetId: last.targetId });
|
|
141
183
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
184
|
+
const truncated = await applyTruncation(renderOutcomes(all, total), "urls");
|
|
185
|
+
const details = {
|
|
186
|
+
tabs: all,
|
|
187
|
+
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
188
|
+
};
|
|
146
189
|
if (okTabs.length === 0) {
|
|
147
|
-
return err({
|
|
148
|
-
kind: "cdp_error",
|
|
149
|
-
message: `All ${total} URLs failed`,
|
|
150
|
-
...(truncated.fullOutputPath ? { details: { tabs: all, fullOutputPath: truncated.fullOutputPath } } : { details: { tabs: all } }),
|
|
151
|
-
});
|
|
190
|
+
return err({ kind: "cdp_error", message: `All ${total} URLs failed`, details });
|
|
152
191
|
}
|
|
153
|
-
return ok({
|
|
154
|
-
text: truncated.text,
|
|
155
|
-
...(truncated.fullOutputPath ? { details: { tabs: all, fullOutputPath: truncated.fullOutputPath } } : { details: { tabs: all } }),
|
|
156
|
-
});
|
|
192
|
+
return ok({ text: truncated.text, details });
|
|
157
193
|
},
|
|
158
194
|
});
|