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.
Files changed (88) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +86 -0
  3. package/package.json +12 -6
  4. package/skills/pi-browser-harness/SKILL.md +57 -2
  5. package/src/cdp/commands.ts +200 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +110 -349
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +21 -22
  25. package/src/daemon/transport.ts +80 -49
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -2
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -64
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +13 -9
  34. package/src/domains/form-batch.ts +13 -10
  35. package/src/domains/form.ts +78 -107
  36. package/src/domains/headers.ts +2 -2
  37. package/src/domains/history.ts +4 -4
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +65 -44
  41. package/src/domains/keyboard.ts +14 -11
  42. package/src/domains/navigate.ts +102 -66
  43. package/src/domains/network.ts +55 -95
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +18 -51
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -53
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -10
  59. package/src/domains/scroll.ts +4 -4
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -33
  62. package/src/domains/snapshot.ts +71 -73
  63. package/src/domains/tab-resolve.ts +36 -0
  64. package/src/domains/tabs.ts +18 -69
  65. package/src/domains/target.ts +1 -1
  66. package/src/index.ts +90 -33
  67. package/src/profile/bind.ts +45 -14
  68. package/src/profile/browser-process.ts +50 -12
  69. package/src/profile/command.ts +20 -12
  70. package/src/profile/launch.ts +13 -9
  71. package/src/profile/list.ts +9 -13
  72. package/src/profile/store.ts +2 -1
  73. package/src/prompt.ts +32 -7
  74. package/src/registry.ts +65 -4
  75. package/src/schemas/parse.ts +2 -2
  76. package/src/setup.ts +28 -19
  77. package/src/state.ts +2 -1
  78. package/src/util/debug.ts +7 -1
  79. package/src/util/guards.ts +3 -0
  80. package/src/util/mutex.ts +1 -1
  81. package/src/util/paths.ts +3 -2
  82. package/src/util/result.ts +8 -0
  83. package/src/util/sharp-shim.ts +18 -11
  84. package/src/util/sleep.ts +18 -0
  85. package/src/util/tool.ts +163 -114
  86. package/src/util/truncate.ts +1 -1
  87. package/tsconfig.json +2 -1
  88. package/src/domains/fill-engine.ts +0 -80
@@ -1,6 +1,7 @@
1
1
  import type { BrowserClient } from "../client";
2
2
  import type { CdpMethod, ParamsOf, ResultOf } from "../cdp/commands";
3
3
  import type { CdpError } from "../cdp/errors";
4
+ import type { CallArgs, CallOpts } from "../cdp/session";
4
5
  import { type Result, err } from "../util/result";
5
6
  import type { ToolErr } from "../util/tool";
6
7
 
@@ -10,13 +11,20 @@ export const cdpErrToToolErr = (e: CdpError, method: string): ToolErr => ({
10
11
  details: { method },
11
12
  });
12
13
 
14
+ // Maps a BrowserClient-level Result (listTabs, newTab, pageInfo, …) to the tool error shape without losing the
15
+ // timeout kind or the CDP method the error carries. `fallbackMethod` names the operation when the error has none.
16
+ export const fromClient = <T>(r: Result<T, CdpError>, fallbackMethod = "client"): Result<T, ToolErr> => {
17
+ if (r.success) return r;
18
+ return err(cdpErrToToolErr(r.error, r.error.method ?? fallbackMethod));
19
+ };
20
+
21
+ // Params are required only when the method has a required key, mirroring CdpSession.call.
13
22
  export const cdpCall = async <M extends CdpMethod>(
14
23
  client: BrowserClient,
15
24
  method: M,
16
- params: ParamsOf<M>,
17
- opts?: { timeoutMs?: number },
25
+ ...args: CallArgs<M>
18
26
  ): Promise<Result<ResultOf<M>, ToolErr>> => {
19
- const r = await client.session().call(method, params, opts);
27
+ const r = await client.session().call(method, ...args);
20
28
  if (!r.success) return err(cdpErrToToolErr(r.error, method));
21
29
  return r;
22
30
  };
@@ -26,7 +34,7 @@ export const cdpCallOnTarget = async <M extends CdpMethod>(
26
34
  method: M,
27
35
  params: ParamsOf<M>,
28
36
  sessionId: string,
29
- opts?: { timeoutMs?: number },
37
+ opts?: CallOpts,
30
38
  ): Promise<Result<ResultOf<M>, ToolErr>> => {
31
39
  const r = await client.session().callOnTarget(method, params, sessionId, opts);
32
40
  if (!r.success) return err(cdpErrToToolErr(r.error, method));
@@ -36,10 +44,9 @@ export const cdpCallOnTarget = async <M extends CdpMethod>(
36
44
  export const cdpCallBrowser = async <M extends CdpMethod>(
37
45
  client: BrowserClient,
38
46
  method: M,
39
- params?: ParamsOf<M>,
40
- opts?: { timeoutMs?: number },
47
+ ...args: CallArgs<M>
41
48
  ): Promise<Result<ResultOf<M>, ToolErr>> => {
42
- const r = await client.session().callBrowser(method, params, opts);
49
+ const r = await client.session().callBrowser(method, ...args);
43
50
  if (!r.success) return err(cdpErrToToolErr(r.error, method));
44
51
  return r;
45
52
  };
@@ -80,13 +80,13 @@ export const clickTool = defineBrowserTool({
80
80
  if (debug.success) {
81
81
  return ok({
82
82
  text: `Clicked at ${target}\n[DEBUG] Overlay screenshot: ${debug.data.path}${diff}`,
83
- details: { debugScreenshotPath: debug.data.path, x, y, ref: args.ref },
83
+ details: { debugScreenshotPath: debug.data.path, x, y, ...(args.ref !== undefined ? { ref: args.ref } : {}) },
84
84
  });
85
85
  }
86
86
  }
87
87
  return ok({
88
88
  text: `Clicked at ${target}${diff}`,
89
- details: { x, y, ref: args.ref },
89
+ details: { x, y, ...(args.ref !== undefined ? { ref: args.ref } : {}) },
90
90
  });
91
91
  },
92
92
  });
@@ -0,0 +1,50 @@
1
+ // A bounded fan-out runner: at most `limit` tasks in flight, nothing started once `signal` aborts, and a task
2
+ // still running at abort time is abandoned (its slot resolves to undefined) so the caller returns on the deadline.
3
+ export const runLimited = async <T, R>(
4
+ items: ReadonlyArray<T>,
5
+ opts: { readonly limit: number; readonly signal: AbortSignal },
6
+ fn: (item: T, signal: AbortSignal) => Promise<R>,
7
+ ): Promise<ReadonlyArray<R | undefined>> => {
8
+ const out: Array<R | undefined> = items.map(() => undefined);
9
+ const { signal } = opts;
10
+ const aborted = new Promise<undefined>((resolve) => {
11
+ if (signal.aborted) resolve(undefined);
12
+ else signal.addEventListener("abort", () => { resolve(undefined); }, { once: true });
13
+ });
14
+ const queue = items.entries();
15
+ const worker = async (): Promise<void> => {
16
+ for (;;) {
17
+ const next = queue.next();
18
+ if (next.done) return;
19
+ const [i, item] = next.value;
20
+ if (signal.aborted) continue;
21
+ try {
22
+ out[i] = await Promise.race([fn(item, signal), aborted]);
23
+ } catch {
24
+ out[i] = undefined;
25
+ }
26
+ }
27
+ };
28
+ const workers = Array.from({ length: Math.max(1, Math.min(opts.limit, items.length)) }, worker);
29
+ await Promise.all(workers);
30
+ return out;
31
+ };
32
+
33
+ // A signal that fires after `ms`, or earlier if `parent` aborts; `clear` releases the timer once the work is done.
34
+ export const deadlineSignal = (
35
+ ms: number,
36
+ parent?: AbortSignal,
37
+ ): { readonly signal: AbortSignal; readonly clear: () => void } => {
38
+ const ac = new AbortController();
39
+ const onParentAbort = (): void => { ac.abort(); };
40
+ const timer = setTimeout(() => { ac.abort(); }, ms);
41
+ if (parent?.aborted) ac.abort();
42
+ else parent?.addEventListener("abort", onParentAbort, { once: true });
43
+ return {
44
+ signal: ac.signal,
45
+ clear: () => {
46
+ clearTimeout(timer);
47
+ parent?.removeEventListener("abort", onParentAbort);
48
+ },
49
+ };
50
+ };
@@ -1,11 +1,10 @@
1
1
  import { Type } from "typebox";
2
- import { Markdown, Text } from "@mariozechner/pi-tui";
2
+ import { Markdown } from "@mariozechner/pi-tui";
3
3
  import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
4
4
  import { type Result, ok } from "../util/result";
5
- import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
+ import { type DetailsOf, defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
6
6
  import { applyTruncation } from "../util/truncate";
7
- import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer";
8
- import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards";
7
+ import { type ConsoleRecord, type ConsoleLevel, ConsoleRecordSchema } from "../cdp/console-buffer";
9
8
 
10
9
  const ConsoleArgs = Type.Object({
11
10
  levels: Type.Optional(
@@ -42,7 +41,7 @@ const levelTag = (lvl: ConsoleLevel): string => {
42
41
  case "warn": return "[warn] ";
43
42
  case "info": return "[info] ";
44
43
  case "debug": return "[debug]";
45
- default: return "[log] ";
44
+ case "log": return "[log] ";
46
45
  }
47
46
  };
48
47
 
@@ -97,59 +96,17 @@ const renderConsoleMarkdown = (
97
96
  return `${headerLines.join("\n")}\n\n${rows}${more}${cursor}`;
98
97
  };
99
98
 
100
- type ConsoleDetails = {
101
- total: number;
102
- returned: number;
103
- bufferOverflowed: boolean;
104
- truncated: boolean;
105
- fullOutputPath?: string;
106
- records: ReadonlyArray<ConsoleRecord>;
107
- nextCursor?: number;
108
- };
109
-
110
- const CONSOLE_LEVELS: ReadonlyArray<ConsoleLevel> = ["log", "info", "warn", "error", "debug"];
111
-
112
- const asConsoleLevel = (v: unknown): ConsoleLevel | undefined =>
113
- CONSOLE_LEVELS.find((l) => l === v);
114
-
115
- const asConsoleRecord = (v: unknown): ConsoleRecord | undefined => {
116
- const o = asRecord(v);
117
- if (o === undefined) return undefined;
118
- const level = asConsoleLevel(o["level"]);
119
- const text = asString(o["text"]);
120
- if (level === undefined || text === undefined) return undefined;
121
- const url = asString(o["url"]);
122
- const lineNumber = asNumber(o["lineNumber"]);
123
- const stackTrace = asString(o["stackTrace"]);
124
- return {
125
- seq: asNumber(o["seq"]) ?? 0,
126
- timestampMs: asNumber(o["timestampMs"]) ?? 0,
127
- level,
128
- text,
129
- source: o["source"] === "log-entry" ? "log-entry" : "console-api",
130
- ...(url !== undefined ? { url } : {}),
131
- ...(lineNumber !== undefined ? { lineNumber } : {}),
132
- ...(stackTrace !== undefined ? { stackTrace } : {}),
133
- };
134
- };
99
+ const ConsoleDetailsSchema = Type.Object({
100
+ total: Type.Number(),
101
+ returned: Type.Number(),
102
+ bufferOverflowed: Type.Boolean(),
103
+ truncated: Type.Boolean(),
104
+ fullOutputPath: Type.Optional(Type.String()),
105
+ records: Type.Array(ConsoleRecordSchema),
106
+ nextCursor: Type.Optional(Type.Number()),
107
+ });
135
108
 
136
- const asConsoleDetails = (v: unknown): ConsoleDetails | undefined => {
137
- const o = asRecord(v);
138
- if (o === undefined) return undefined;
139
- const records = asArrayOf(o["records"], asConsoleRecord);
140
- if (records === undefined) return undefined;
141
- const fullOutputPath = asString(o["fullOutputPath"]);
142
- const nextCursor = asNumber(o["nextCursor"]);
143
- return {
144
- total: asNumber(o["total"]) ?? records.length,
145
- returned: asNumber(o["returned"]) ?? records.length,
146
- bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false,
147
- truncated: asBoolean(o["truncated"]) ?? false,
148
- records,
149
- ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
150
- ...(nextCursor !== undefined ? { nextCursor } : {}),
151
- };
152
- };
109
+ export type ConsoleDetails = DetailsOf<typeof ConsoleDetailsSchema>;
153
110
 
154
111
  export const consoleTool = defineBrowserTool({
155
112
  name: "browser_console",
@@ -162,9 +119,10 @@ export const consoleTool = defineBrowserTool({
162
119
  "Don't use as a default observation tool — prefer browser_snapshot for page structure.",
163
120
  ],
164
121
  parameters: ConsoleArgs,
122
+ details: ConsoleDetailsSchema,
165
123
  concurrency: "parallel",
166
124
 
167
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
125
+ async handler(args, { client }): Promise<Result<ToolOk<ConsoleDetails>, ToolErr>> {
168
126
  const session = client.session();
169
127
  const drained = session.drainConsoleBuffer({
170
128
  ...(args.levels !== undefined ? { levels: args.levels } : {}),
@@ -192,16 +150,14 @@ export const consoleTool = defineBrowserTool({
192
150
  truncated: trunc.wasTruncated,
193
151
  records: drained.records,
194
152
  ...(nextCursor !== undefined ? { nextCursor } : {}),
153
+ ...(trunc.fullOutputPath !== undefined ? { fullOutputPath: trunc.fullOutputPath } : {}),
195
154
  };
196
- if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
197
155
 
198
156
  return ok({ text: trunc.text, details });
199
157
  },
200
158
 
201
- renderResult(result, expanded, theme) {
202
- const details = asConsoleDetails(result.details);
203
- if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0);
204
-
159
+ renderResult(result, expanded) {
160
+ const details = result.details;
205
161
  const md = renderConsoleMarkdown(details.records, {
206
162
  total: details.total,
207
163
  bufferOverflowed: details.bufferOverflowed,
@@ -209,7 +165,7 @@ export const consoleTool = defineBrowserTool({
209
165
  nextCursor: details.nextCursor,
210
166
  });
211
167
  const tail = expanded
212
- ? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
168
+ ? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath !== undefined ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
213
169
  : `\n\n${keyHint("app.tools.expand", "to expand")}`;
214
170
 
215
171
  return new Markdown(md + tail, 0, 0, getMarkdownTheme());
@@ -1,4 +1,5 @@
1
1
  import { Type } from "typebox";
2
+ import type { ParamsOf } from "../cdp/commands";
2
3
  import { type Result, ok } from "../util/result";
3
4
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
5
  import { cdpCall } from "./cdp-call";
@@ -27,37 +28,34 @@ export const dragAndDropTool = defineBrowserTool({
27
28
  parameters: DragArgs,
28
29
  concurrency: "serialized",
29
30
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
30
- const data = args.dataTransfer
31
- ? {
32
- items: Object.entries(args.dataTransfer).map(([mimeType, payload]) => ({
33
- mimeType,
34
- data: Buffer.from(payload).toString("base64"),
35
- })),
36
- dragOperationsMask: 1,
37
- }
38
- : { items: [], dragOperationsMask: 1 };
39
- type InputCall = readonly ["Input.dispatchMouseEvent" | "Input.dispatchDragEvent", Record<string, unknown>];
40
- const calls: ReadonlyArray<InputCall> = [
41
- ["Input.dispatchMouseEvent", { type: "mousePressed", x: args.startX, y: args.startY, button: "left", clickCount: 1 }],
42
- ["Input.dispatchDragEvent", { type: "dragEnter", x: args.startX, y: args.startY, data, modifiers: 0 }],
43
- ...Array.from({ length: 5 }, (_, i): InputCall => {
31
+ const data: ParamsOf<"Input.dispatchDragEvent">["data"] = {
32
+ items: Object.entries(args.dataTransfer ?? {}).map(([mimeType, payload]) => ({
33
+ mimeType,
34
+ data: Buffer.from(payload).toString("base64"),
35
+ })),
36
+ dragOperationsMask: 1,
37
+ };
38
+ // Each step is a thunk so the whole sequence is typed against the real Input schemas and still runs in order.
39
+ const mouse = (p: ParamsOf<"Input.dispatchMouseEvent">) => () => cdpCall(client, "Input.dispatchMouseEvent", p);
40
+ const drag = (p: ParamsOf<"Input.dispatchDragEvent">) => () => cdpCall(client, "Input.dispatchDragEvent", p);
41
+ const steps = [
42
+ mouse({ type: "mousePressed", x: args.startX, y: args.startY, button: "left", clickCount: 1 }),
43
+ drag({ type: "dragEnter", x: args.startX, y: args.startY, data, modifiers: 0 }),
44
+ ...Array.from({ length: 5 }, (_, i) => {
44
45
  const t = (i + 1) / 5;
45
- return [
46
- "Input.dispatchDragEvent",
47
- {
48
- type: "dragOver",
49
- x: Math.round(args.startX + (args.endX - args.startX) * t),
50
- y: Math.round(args.startY + (args.endY - args.startY) * t),
51
- data,
52
- modifiers: 0,
53
- },
54
- ];
46
+ return drag({
47
+ type: "dragOver",
48
+ x: Math.round(args.startX + (args.endX - args.startX) * t),
49
+ y: Math.round(args.startY + (args.endY - args.startY) * t),
50
+ data,
51
+ modifiers: 0,
52
+ });
55
53
  }),
56
- ["Input.dispatchDragEvent", { type: "drop", x: args.endX, y: args.endY, data, modifiers: 0 }],
57
- ["Input.dispatchMouseEvent", { type: "mouseReleased", x: args.endX, y: args.endY, button: "left", clickCount: 1 }],
54
+ drag({ type: "drop", x: args.endX, y: args.endY, data, modifiers: 0 }),
55
+ mouse({ type: "mouseReleased", x: args.endX, y: args.endY, button: "left", clickCount: 1 }),
58
56
  ];
59
- for (const [method, params] of calls) {
60
- const r = await cdpCall(client, method, params);
57
+ for (const step of steps) {
58
+ const r = await step();
61
59
  if (!r.success) return r;
62
60
  }
63
61
  return ok({
@@ -1,82 +1,120 @@
1
+ import { isRecord } from "../util/guards";
1
2
  import type { BrowserClient } from "../client";
2
3
  import { safeJs } from "../util/js-template";
3
4
  import { type Result, err, ok } from "../util/result";
4
- import type { ToolErr } from "../util/tool";
5
+ import type { JsonObject, ToolErr } from "../util/tool";
5
6
  import { cdpCall, evalJs } from "./cdp-call";
7
+ import type { PageOption, PageResult } from "./page/fill.page";
6
8
  import { resolveRefToObjectId } from "./ref-resolve";
7
9
 
8
- export type PageResult = {
9
- readonly ok: boolean;
10
- readonly reason?: string;
11
- readonly kind?: string;
12
- readonly value?: unknown;
13
- readonly text?: string;
14
- readonly checked?: boolean;
15
- readonly changed?: boolean;
16
- readonly tag?: string;
17
- readonly options?: ReadonlyArray<{ value: string; text: string }>;
18
- };
10
+ // PageResult is declared once, in the page module that produces it (page/fill.page.ts), so the guard below is checked
11
+ // against the same declaration the page function returns.
12
+ export type { PageOption, PageResult };
19
13
 
20
- const isOption = (v: unknown): v is { readonly value: string; readonly text: string } =>
14
+ const isOption = (v: unknown): v is PageOption =>
21
15
  typeof v === "object" && v !== null
22
16
  && "value" in v && typeof v.value === "string"
23
17
  && "text" in v && typeof v.text === "string";
24
18
 
19
+ const hasTag = (v: object): v is { readonly tag: string } => "tag" in v && typeof v.tag === "string";
20
+
25
21
  export const isPageResult = (v: unknown): v is PageResult => {
26
22
  if (typeof v !== "object" || v === null) return false;
27
23
  if (!("ok" in v) || typeof v.ok !== "boolean") return false;
28
- if ("reason" in v && typeof v.reason !== "string") return false;
29
- if ("kind" in v && typeof v.kind !== "string") return false;
30
- if ("text" in v && typeof v.text !== "string") return false;
31
- if ("checked" in v && typeof v.checked !== "boolean") return false;
32
- if ("changed" in v && typeof v.changed !== "boolean") return false;
33
- if ("tag" in v && typeof v.tag !== "string") return false;
34
- if ("options" in v && !(Array.isArray(v.options) && v.options.every(isOption))) return false;
35
- return true;
24
+ if (!v.ok) {
25
+ if (!("reason" in v) || typeof v.reason !== "string") return false;
26
+ if ("tag" in v && typeof v.tag !== "string") return false;
27
+ if ("code" in v && v.code !== "select_rejected" && v.code !== "no_match") return false;
28
+ if ("options" in v && !(Array.isArray(v.options) && v.options.every(isOption))) return false;
29
+ return true;
30
+ }
31
+ if (!("kind" in v) || typeof v.kind !== "string" || !hasTag(v)) return false;
32
+ switch (v.kind) {
33
+ case "select":
34
+ return "value" in v && typeof v.value === "string" && "text" in v && typeof v.text === "string";
35
+ case "checkbox":
36
+ case "radio":
37
+ return "checked" in v && typeof v.checked === "boolean" && "changed" in v && typeof v.changed === "boolean";
38
+ case "input":
39
+ case "textarea":
40
+ case "contenteditable":
41
+ return "value" in v && typeof v.value === "string";
42
+ default:
43
+ return false;
44
+ }
45
+ };
46
+
47
+ // The one not-found shape for element lookups: the page never reports it, the harness does.
48
+ export type ElementOutcome =
49
+ | { readonly found: false }
50
+ | { readonly found: true; readonly value: unknown };
51
+
52
+ const isElementOutcome = (v: unknown): v is ElementOutcome => {
53
+ if (!isRecord(v)) return false;
54
+ if (v["found"] === false) return true;
55
+ return v["found"] === true && "value" in v;
56
+ };
57
+
58
+ const callOnObject = async (
59
+ client: BrowserClient,
60
+ objectId: string,
61
+ functionDeclaration: string,
62
+ args: ReadonlyArray<unknown>,
63
+ ref: string,
64
+ ): Promise<Result<unknown, ToolErr>> => {
65
+ try {
66
+ const called = await cdpCall(client, "Runtime.callFunctionOn", {
67
+ objectId,
68
+ functionDeclaration,
69
+ arguments: args.map((v) => ({ value: v })),
70
+ returnByValue: true,
71
+ awaitPromise: true,
72
+ });
73
+ if (!called.success) return err({ ...called.error, details: { ...(called.error.details ?? {}), ref } });
74
+ if (called.data.exceptionDetails !== undefined) {
75
+ return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(called.data.exceptionDetails)}`, details: { ref } });
76
+ }
77
+ return ok(called.data.result.value);
78
+ } finally {
79
+ await cdpCall(client, "Runtime.releaseObject", { objectId });
80
+ }
36
81
  };
37
82
 
83
+ // Runs an element function (its declaration from page/inject.ts elementFunctionSource) on the element a ref or
84
+ // selector names. A ref goes through Runtime.callFunctionOn; a selector is looked up in one Runtime.evaluate round trip.
38
85
  export const runOnElement = async (
39
86
  client: BrowserClient,
40
87
  opts: {
41
88
  ref?: string | undefined;
42
89
  selector?: string | undefined;
43
- fnBody: string;
44
- arg: unknown;
45
- notFound?: unknown;
90
+ functionDeclaration: string;
91
+ args: ReadonlyArray<unknown>;
46
92
  },
47
- ): Promise<Result<unknown, ToolErr>> => {
93
+ ): Promise<Result<ElementOutcome, ToolErr>> => {
48
94
  if (opts.ref !== undefined) {
49
95
  const objectId = await resolveRefToObjectId(client, opts.ref);
50
96
  if (!objectId.success) return objectId;
51
- const r = await cdpCall(client, "Runtime.callFunctionOn", {
52
- objectId: objectId.data,
53
- functionDeclaration: `function (arg) { ${opts.fnBody} }`,
54
- arguments: [{ value: opts.arg }],
55
- returnByValue: true,
56
- });
97
+ const r = await callOnObject(client, objectId.data, opts.functionDeclaration, opts.args, opts.ref);
57
98
  if (!r.success) return r;
58
- if (r.data.exceptionDetails !== undefined) {
59
- return err({
60
- kind: "cdp_error",
61
- message: `page function threw: ${r.data.exceptionDetails.text}`,
62
- details: { ref: opts.ref },
63
- });
64
- }
65
- return ok(r.data.result.value);
99
+ return ok({ found: true, value: r.data });
66
100
  }
67
101
  if (opts.selector === undefined) {
68
- return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
102
+ return err({ kind: "invalid_args", message: "Provide either `ref` or `selector`." });
69
103
  }
70
- // fnBody is harness-authored, not user input; only the selector and value are interpolated, and those go through safeJs.
104
+ // The function source is harness-authored, not user input; only the selector and the arguments are interpolated,
105
+ // and those go through safeJs.
71
106
  const prelude = safeJs`
72
107
  (() => {
73
108
  const el = document.querySelector(${opts.selector});
74
- if (!el) return ${opts.notFound ?? { status: "not_found" }};
75
- const __arg = ${opts.arg};
76
- return (function (arg) {`;
77
- const expr = `${prelude} ${opts.fnBody} }).call(el, __arg); })()`;
109
+ if (!el) return { found: false };
110
+ const __args = ${opts.args};
111
+ return { found: true, value: (`;
112
+ const expr = prelude + opts.functionDeclaration + ").apply(el, __args) }; })()";
78
113
  const r = await evalJs(client, expr);
79
114
  if (!r.success) return r;
115
+ if (!isElementOutcome(r.data)) {
116
+ return err({ kind: "internal", message: "unexpected page result shape from element lookup", details: { selector: opts.selector } });
117
+ }
80
118
  return ok(r.data);
81
119
  };
82
120
 
@@ -88,30 +126,26 @@ export const resolveAndCall = async (
88
126
  ): Promise<Result<PageResult, ToolErr>> => {
89
127
  const objectId = await resolveRefToObjectId(client, ref);
90
128
  if (!objectId.success) return objectId;
91
- const handle = objectId.data;
92
- try {
93
- const called = await cdpCall(client, "Runtime.callFunctionOn", {
94
- objectId: handle,
95
- functionDeclaration,
96
- arguments: args.map((v) => ({ value: v })),
97
- returnByValue: true,
98
- awaitPromise: true,
99
- });
100
- if (!called.success) return err({ ...called.error, details: { ref } });
101
- if (called.data.exceptionDetails !== undefined) {
102
- return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(called.data.exceptionDetails)}`, details: { ref } });
103
- }
104
- const value = called.data.result.value;
105
- if (!isPageResult(value)) {
106
- return err({ kind: "internal", message: "page function returned no result object", details: { ref } });
107
- }
108
- return ok(value);
109
- } finally {
110
- await cdpCall(client, "Runtime.releaseObject", { objectId: handle });
129
+ const r = await callOnObject(client, objectId.data, functionDeclaration, args, ref);
130
+ if (!r.success) return r;
131
+ if (!isPageResult(r.data)) {
132
+ return err({ kind: "internal", message: "unexpected page result shape", details: { ref } });
111
133
  }
134
+ return ok(r.data);
112
135
  };
113
136
 
114
- export const detailsOf = (ref: string, res: PageResult, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
115
- const { ok: _ok, ...rest } = res;
116
- return { ref, ...rest, ...(extra ?? {}) };
137
+ // The page result minus its `ok` flag (the caller has already branched on it), as JSON for a tool's details.
138
+ export const detailsOf = (ref: string, res: PageResult): JsonObject => {
139
+ if (!res.ok) {
140
+ return {
141
+ ref,
142
+ reason: res.reason,
143
+ ...(res.tag !== undefined ? { tag: res.tag } : {}),
144
+ ...(res.code !== undefined ? { code: res.code } : {}),
145
+ ...(res.options !== undefined ? { options: res.options } : {}),
146
+ };
147
+ }
148
+ if ("checked" in res) return { ref, kind: res.kind, checked: res.checked, changed: res.changed, tag: res.tag };
149
+ if (res.kind === "select") return { ref, kind: res.kind, value: res.value, text: res.text, tag: res.tag };
150
+ return { ref, kind: res.kind, value: res.value, tag: res.tag };
117
151
  };
@@ -1,4 +1,4 @@
1
- import { mkdir, access, constants, stat, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, access, constants, stat } from "node:fs/promises";
2
2
  import { basename } from "node:path";
3
3
  import { Type } from "typebox";
4
4
  import type { BrowserClient } from "../client";
@@ -7,6 +7,7 @@ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
7
  import { safeJs } from "../util/js-template";
8
8
  import { pdfPath } from "../util/paths";
9
9
  import { cdpCall, cdpCallBrowser, evalJs } from "./cdp-call";
10
+ import { readFileResult, statResult, writeFileResult } from "./io";
10
11
  import { resolveRefToBackendId } from "./ref-resolve";
11
12
 
12
13
  const UploadArgs = Type.Object({
@@ -73,18 +74,20 @@ const jsFallbackUpload = async (
73
74
  selector: string,
74
75
  filePath: string,
75
76
  ): Promise<Result<void, ToolErr>> => {
76
- const buf = await readFile(filePath);
77
- const st = await stat(filePath);
77
+ const buf = await readFileResult(filePath);
78
+ if (!buf.success) return buf;
79
+ const st = await statResult(filePath);
80
+ if (!st.success) return st;
78
81
  const name = basename(filePath);
79
82
  const mime = inferMime(name);
80
83
  const expr = safeJs`
81
84
  (() => {
82
85
  const input = document.querySelector(${selector});
83
86
  if (!input || input.type !== 'file') throw new Error('File input not found');
84
- const bin = atob(${buf.toString("base64")});
87
+ const bin = atob(${buf.data.toString("base64")});
85
88
  const bytes = new Uint8Array(bin.length);
86
89
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
87
- const file = new File([bytes], ${name}, { type: ${mime}, lastModified: ${st.mtimeMs} });
90
+ const file = new File([bytes], ${name}, { type: ${mime}, lastModified: ${st.data.mtimeMs} });
88
91
  const dt = new DataTransfer();
89
92
  dt.items.add(file);
90
93
  input.files = dt.files;
@@ -122,7 +125,7 @@ export const uploadFileTool = defineBrowserTool({
122
125
  }
123
126
 
124
127
  if (args.selector === undefined) {
125
- return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
128
+ return err({ kind: "invalid_args", message: "Provide either `ref` or `selector`." });
126
129
  }
127
130
  const cdp = await tryCdpUpload(client, args.selector, args.filePath);
128
131
  if (cdp.success) {
@@ -199,7 +202,7 @@ export const downloadTool = defineBrowserTool({
199
202
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
200
203
  if (args.restore === true) {
201
204
  if (args.downloadPath !== undefined) {
202
- return err({ kind: "invalid_state", message: "Pass either downloadPath or restore, not both" });
205
+ return err({ kind: "invalid_args", message: "Pass either downloadPath or restore, not both" });
203
206
  }
204
207
  const r = await cdpCallBrowser(client, "Browser.setDownloadBehavior", { behavior: "default" });
205
208
  if (!r.success) return r;
@@ -207,7 +210,7 @@ export const downloadTool = defineBrowserTool({
207
210
  return ok({ text: "Downloads restored to Chrome's default location", details: { restored: true } });
208
211
  }
209
212
  if (args.downloadPath === undefined) {
210
- return err({ kind: "invalid_state", message: "Provide downloadPath to set a directory, or restore:true to undo" });
213
+ return err({ kind: "invalid_args", message: "Provide downloadPath to set a directory, or restore:true to undo" });
211
214
  }
212
215
  try {
213
216
  await mkdir(args.downloadPath, { recursive: true });
@@ -262,7 +265,8 @@ export const printToPdfTool = defineBrowserTool({
262
265
  });
263
266
  if (!r.success) return r;
264
267
  const path = args.outputPath ?? pdfPath(client.namespace);
265
- await writeFile(path, Buffer.from(r.data.data, "base64"));
268
+ const written = await writeFileResult(path, Buffer.from(r.data.data, "base64"));
269
+ if (!written.success) return written;
266
270
  return ok({ text: `PDF saved: ${path}`, details: { path } });
267
271
  },
268
272
  });