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,10 +1,12 @@
1
1
  import { Type } from "typebox";
2
2
  import { type Result, err, ok } from "../util/result";
3
- import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
- import { fillDeclaration } from "./fill-engine";
3
+ import { defineBrowserTool, type JsonObject, type ToolErr, type ToolOk } from "../util/tool";
5
4
  import { detailsOf, resolveAndCall } from "./element-call";
5
+ import { fillField, type FillOptions } from "./page/fill.page";
6
+ import { elementFunctionSource } from "./page/inject";
6
7
 
7
- const FILL_FN = fillDeclaration({ rejectSelect: false, focusFirst: true });
8
+ const FILL_FN = elementFunctionSource(fillField);
9
+ const FILL_OPTIONS: FillOptions = { rejectSelect: false, focusFirst: true };
8
10
 
9
11
  const FieldValue = Type.Union([Type.String(), Type.Boolean()], {
10
12
  description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.",
@@ -34,23 +36,24 @@ export const fillFormTool = defineBrowserTool({
34
36
  parameters: FillFormArgs,
35
37
  concurrency: "serialized",
36
38
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
37
- const results: Array<Readonly<Record<string, unknown>>> = [];
39
+ const results: JsonObject[] = [];
38
40
  let okCount = 0;
39
41
  const issues: string[] = [];
40
42
  for (const f of args.fields) {
41
- const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]);
43
+ const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value, FILL_OPTIONS]);
42
44
  if (!r.success) {
43
45
  results.push({ ref: f.ref, ok: false, error: r.error.message });
44
46
  issues.push(`ref ${f.ref} (${r.error.message})`);
45
47
  continue;
46
48
  }
47
49
  const res = r.data;
48
- const wanted = typeof f.value === "boolean" ? undefined : String(f.value);
49
- const got = res.value !== undefined ? String(res.value) : undefined;
50
- const mismatch = wanted !== undefined && got !== undefined && got !== wanted && res.text !== wanted;
51
- const fieldOk = res.ok === true && !mismatch;
50
+ const wanted = typeof f.value === "boolean" ? undefined : f.value;
51
+ const got = res.ok && "value" in res ? res.value : undefined;
52
+ const label = res.ok && res.kind === "select" ? res.text : undefined;
53
+ const mismatch = wanted !== undefined && got !== undefined && got !== wanted && label !== wanted;
54
+ const fieldOk = res.ok && !mismatch;
52
55
  if (fieldOk) okCount++;
53
- else issues.push(`ref ${f.ref} (${res.ok === false ? (res.reason ?? "failed") : "value mismatch"})`);
56
+ else issues.push(`ref ${f.ref} (${res.ok ? "value mismatch" : res.reason})`);
54
57
  results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
55
58
  }
56
59
  const text =
@@ -1,10 +1,47 @@
1
1
  import { Type } from "typebox";
2
2
  import { type Result, err, ok } from "../util/result";
3
- import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
- import { detailsOf, isPageResult, type PageResult, resolveAndCall, runOnElement } from "./element-call";
5
- import { fillBody } from "./fill-engine";
3
+ import { defineBrowserTool, type JsonObject, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { detailsOf, type ElementOutcome, isPageResult, resolveAndCall, runOnElement } from "./element-call";
5
+ import { fillField, type FillOptions } from "./page/fill.page";
6
+ import { type FocusResult, focusElement, type SelectOption, type SelectResult, type SelectWanted, selectOption, setChecked } from "./page/form.page";
7
+ import { elementFunctionSource } from "./page/inject";
6
8
  import { interactiveDiff } from "./ref-resolve";
7
9
 
10
+ type Target = { readonly ref?: string | undefined; readonly selector?: string | undefined };
11
+
12
+ const targetLabel = (t: Target): string => t.ref ?? t.selector ?? "";
13
+
14
+ // Only the locator the caller actually passed goes into details; an undefined key would not survive pi's JSON store.
15
+ const targetDetails = (t: Target): JsonObject => ({
16
+ ...(t.ref !== undefined ? { ref: t.ref } : {}),
17
+ ...(t.selector !== undefined ? { selector: t.selector } : {}),
18
+ });
19
+
20
+ const noMatch = (t: Target): ToolErr => ({
21
+ kind: "invalid_state",
22
+ message: `No element matched: ${targetLabel(t)}`,
23
+ details: targetDetails(t),
24
+ });
25
+
26
+ // A page function that returned something the guard does not recognise is a harness bug, not a missing element.
27
+ const badShape = (t: Target, what: string): ToolErr => ({
28
+ kind: "internal",
29
+ message: `unexpected page result shape from ${what}`,
30
+ details: targetDetails(t),
31
+ });
32
+
33
+ // Narrows an element outcome to the page's typed result, or returns the error to surface.
34
+ const narrow = <T>(
35
+ outcome: ElementOutcome,
36
+ t: Target,
37
+ what: string,
38
+ guard: (v: unknown) => v is T,
39
+ ): Result<T, ToolErr> => {
40
+ if (!outcome.found) return err(noMatch(t));
41
+ if (!guard(outcome.value)) return err(badShape(t, what));
42
+ return ok(outcome.value);
43
+ };
44
+
8
45
  const FillArgs = Type.Object({
9
46
  ref: Type.Optional(
10
47
  Type.String({
@@ -16,9 +53,8 @@ const FillArgs = Type.Object({
16
53
  value: Type.String({ description: "Value to set in the field" }),
17
54
  });
18
55
 
19
- const FILL_BODY = `const value = arg;\n${fillBody({ rejectSelect: true, focusFirst: false })}`;
20
-
21
- const NOT_FOUND: PageResult = { ok: false, reason: "not_found" };
56
+ const FILL_FN = elementFunctionSource(fillField);
57
+ const FILL_OPTIONS: FillOptions = { rejectSelect: true, focusFirst: false };
22
58
 
23
59
  export const fillTool = defineBrowserTool({
24
60
  name: "browser_fill",
@@ -37,50 +73,29 @@ export const fillTool = defineBrowserTool({
37
73
  parameters: FillArgs,
38
74
  concurrency: "serialized",
39
75
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
40
- const r = await runOnElement(client, {
41
- ref: args.ref,
42
- selector: args.selector,
43
- fnBody: FILL_BODY,
44
- arg: args.value,
45
- notFound: NOT_FOUND,
46
- });
76
+ const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: FILL_FN, args: [args.value, FILL_OPTIONS] });
47
77
  if (!r.success) return r;
48
- const res = isPageResult(r.data) ? r.data : undefined;
49
- const target = args.ref ?? args.selector ?? "";
50
- if (res === undefined || res.reason === "not_found") {
51
- return err({
52
- kind: "invalid_state",
53
- message: `No element matched: ${target}`,
54
- details: { ref: args.ref, selector: args.selector },
55
- });
56
- }
78
+ const narrowed = narrow(r.data, args, "browser_fill", isPageResult);
79
+ if (!narrowed.success) return narrowed;
80
+ const res = narrowed.data;
81
+ const target = targetLabel(args);
57
82
  if (!res.ok) {
58
83
  const details = { ref: args.ref, selector: args.selector, ...(res.tag !== undefined ? { tag: res.tag } : {}) };
59
- if (res.kind === "select") {
84
+ if (res.code === "select_rejected") {
60
85
  return err({
61
86
  kind: "invalid_state",
62
87
  message: `Element <select> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
63
88
  details,
64
89
  });
65
90
  }
66
- return err({
67
- kind: "invalid_state",
68
- message: `Could not fill ${target}: ${res.reason ?? "unknown reason"}`,
69
- details,
70
- });
91
+ return err({ kind: "invalid_state", message: `Could not fill ${target}: ${res.reason}`, details });
71
92
  }
72
- const shown = res.value !== undefined ? JSON.stringify(res.value) : `checked=${res.checked === true}`;
93
+ const verified = "checked" in res ? res.checked : res.value;
94
+ const shown = "checked" in res ? `checked=${res.checked}` : JSON.stringify(res.value);
73
95
  const diff = await interactiveDiff(client);
74
96
  return ok({
75
97
  text: `Filled ${target} = ${shown}${diff}`,
76
- details: {
77
- ref: args.ref,
78
- selector: args.selector,
79
- value: args.value,
80
- verified: res.value ?? res.checked,
81
- ...(res.tag !== undefined ? { tag: res.tag } : {}),
82
- ...(res.kind !== undefined ? { kind: res.kind } : {}),
83
- },
98
+ details: { ...targetDetails(args), value: args.value, verified, tag: res.tag, kind: res.kind },
84
99
  });
85
100
  },
86
101
  });
@@ -92,21 +107,13 @@ const FocusArgs = Type.Object({
92
107
  selector: Type.Optional(Type.String({ description: "CSS selector of the element to focus (fallback when no ref)" })),
93
108
  });
94
109
 
95
- type FocusResult = { status: "ok"; tag: string } | { status: "not_found" } | { status: "not_focusable"; tag: string };
96
-
97
110
  const isFocusResult = (v: unknown): v is FocusResult => {
98
111
  if (typeof v !== "object" || v === null || !("status" in v)) return false;
99
- if (v.status === "not_found") return true;
100
112
  if (v.status === "ok" || v.status === "not_focusable") return "tag" in v && typeof v.tag === "string";
101
113
  return false;
102
114
  };
103
115
 
104
- const FOCUS_FN = `
105
- const el = this;
106
- if (typeof el.focus !== "function") return { status: "not_focusable", tag: el.tagName };
107
- el.focus();
108
- return document.activeElement === el ? { status: "ok", tag: el.tagName } : { status: "not_focusable", tag: el.tagName };
109
- `;
116
+ const FOCUS_FN = elementFunctionSource(focusElement);
110
117
 
111
118
  export const focusTool = defineBrowserTool({
112
119
  name: "browser_focus",
@@ -120,17 +127,16 @@ export const focusTool = defineBrowserTool({
120
127
  parameters: FocusArgs,
121
128
  concurrency: "serialized",
122
129
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
123
- const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FOCUS_FN, arg: "" });
130
+ const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: FOCUS_FN, args: [] });
124
131
  if (!r.success) return r;
125
- const res = isFocusResult(r.data) ? r.data : undefined;
126
- const target = args.ref ?? args.selector ?? "";
127
- if (res === undefined || res.status === "not_found") {
128
- return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
129
- }
132
+ const narrowed = narrow(r.data, args, "browser_focus", isFocusResult);
133
+ if (!narrowed.success) return narrowed;
134
+ const res = narrowed.data;
135
+ const target = targetLabel(args);
130
136
  if (res.status === "not_focusable") {
131
- return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> could not be focused: ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
137
+ return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> could not be focused: ${target}`, details: { ...targetDetails(args), tag: res.tag } });
132
138
  }
133
- return ok({ text: `Focused ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
139
+ return ok({ text: `Focused ${target}`, details: { ...targetDetails(args), tag: res.tag } });
134
140
  },
135
141
  });
136
142
 
@@ -144,20 +150,13 @@ const SelectOptionArgs = Type.Object({
144
150
  index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based option index to select" })),
145
151
  });
146
152
 
147
- type SelectResult =
148
- | { status: "ok"; value: string; label: string }
149
- | { status: "not_found" }
150
- | { status: "not_select"; tag: string }
151
- | { status: "no_match"; options: ReadonlyArray<{ value: string; label: string }> };
152
-
153
- const isSelectOption = (v: unknown): v is { value: string; label: string } =>
153
+ const isSelectOption = (v: unknown): v is SelectOption =>
154
154
  typeof v === "object" && v !== null
155
155
  && "value" in v && typeof v.value === "string"
156
156
  && "label" in v && typeof v.label === "string";
157
157
 
158
158
  const isSelectResult = (v: unknown): v is SelectResult => {
159
159
  if (typeof v !== "object" || v === null || !("status" in v)) return false;
160
- if (v.status === "not_found") return true;
161
160
  if (v.status === "not_select") return "tag" in v && typeof v.tag === "string";
162
161
  if (v.status === "ok") {
163
162
  return "value" in v && typeof v.value === "string" && "label" in v && typeof v.label === "string";
@@ -168,21 +167,7 @@ const isSelectResult = (v: unknown): v is SelectResult => {
168
167
  return false;
169
168
  };
170
169
 
171
- const SELECT_FN = `
172
- const el = this;
173
- if (el.tagName !== "SELECT") return { status: "not_select", tag: el.tagName };
174
- const opts = Array.from(el.options);
175
- const wv = arg.wv, wl = arg.wl, wi = arg.wi;
176
- let opt = null;
177
- if (wi !== null) opt = opts[wi] || null;
178
- else if (wv !== null) opt = opts.find(o => o.value === wv) || null;
179
- else if (wl !== null) opt = opts.find(o => o.text === wl) || null;
180
- if (!opt) return { status: "no_match", options: opts.map(o => ({ value: o.value, label: o.text })) };
181
- el.value = opt.value;
182
- el.dispatchEvent(new Event("input", { bubbles: true }));
183
- el.dispatchEvent(new Event("change", { bubbles: true }));
184
- return { status: "ok", value: opt.value, label: opt.text };
185
- `;
170
+ const SELECT_FN = elementFunctionSource(selectOption);
186
171
 
187
172
  export const selectOptionTool = defineBrowserTool({
188
173
  name: "browser_select_option",
@@ -198,51 +183,34 @@ export const selectOptionTool = defineBrowserTool({
198
183
  concurrency: "serialized",
199
184
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
200
185
  if (args.value === undefined && args.label === undefined && args.index === undefined) {
201
- return err({ kind: "invalid_state", message: "Provide one of value, label, or index." });
186
+ return err({ kind: "invalid_args", message: "Provide one of value, label, or index." });
202
187
  }
203
- // null sentinels distinguish "not provided" from a real empty-string match.
204
- const arg = { wv: args.value ?? null, wl: args.label ?? null, wi: args.index ?? null };
205
- const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: SELECT_FN, arg });
188
+ const wanted: SelectWanted = { wv: args.value ?? null, wl: args.label ?? null, wi: args.index ?? null };
189
+ const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: SELECT_FN, args: [wanted] });
206
190
  if (!r.success) return r;
207
- const res = isSelectResult(r.data) ? r.data : undefined;
208
- const target = args.ref ?? args.selector ?? "";
209
- if (res === undefined || res.status === "not_found") {
210
- return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
211
- }
191
+ const narrowed = narrow(r.data, args, "browser_select_option", isSelectResult);
192
+ if (!narrowed.success) return narrowed;
193
+ const res = narrowed.data;
194
+ const target = targetLabel(args);
212
195
  if (res.status === "not_select") {
213
- return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> is not a <select>: ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
196
+ return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> is not a <select>: ${target}`, details: { ...targetDetails(args), tag: res.tag } });
214
197
  }
215
198
  if (res.status === "no_match") {
216
199
  return err({
217
200
  kind: "invalid_state",
218
201
  message: `No matching option in ${target}. Available: ${res.options.map((o) => o.label).join(", ")}`,
219
- details: { ref: args.ref, selector: args.selector, options: res.options },
202
+ details: { ...targetDetails(args), options: res.options },
220
203
  });
221
204
  }
222
205
  const diff = await interactiveDiff(client);
223
206
  return ok({
224
207
  text: `Selected "${res.label}" (value=${JSON.stringify(res.value)}) in ${target}${diff}`,
225
- details: { ref: args.ref, selector: args.selector, value: res.value, label: res.label },
208
+ details: { ...targetDetails(args), value: res.value, label: res.label },
226
209
  });
227
210
  },
228
211
  });
229
212
 
230
- const CHECK_FN = `
231
- function(checked) {
232
- const el = this;
233
- const tag = (el.tagName || "").toLowerCase();
234
- const type = (el.type || "").toLowerCase();
235
- if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" };
236
- if (el.disabled) return { ok: false, reason: "element is disabled" };
237
- const want = checked === true;
238
- let changed = false;
239
- if (el.checked !== want) {
240
- try { el.click(); } catch (e) {}
241
- if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); }
242
- changed = true;
243
- }
244
- return { ok: true, checked: el.checked, changed: changed };
245
- }`;
213
+ const CHECK_FN = elementFunctionSource(setChecked);
246
214
 
247
215
  const SetCheckedArgs = Type.Object({
248
216
  ref: Type.String({ description: "Stable element ref of a checkbox or radio from browser_snapshot (e.g. 'e7')." }),
@@ -265,10 +233,13 @@ export const setCheckedTool = defineBrowserTool({
265
233
  if (!r.success) return r;
266
234
  const res = r.data;
267
235
  if (!res.ok) {
268
- return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
236
+ return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason}`, details: detailsOf(args.ref, res) });
237
+ }
238
+ if (!("checked" in res)) {
239
+ return err({ kind: "internal", message: `unexpected page result shape from browser_set_checked (kind ${res.kind})`, details: { ref: args.ref } });
269
240
  }
270
241
  return ok({
271
- text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed === false ? " (already)" : ""),
242
+ text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed ? "" : " (already)"),
272
243
  details: detailsOf(args.ref, res),
273
244
  });
274
245
  },
@@ -23,7 +23,7 @@ const currentOwnedTarget = (
23
23
  client: BrowserClient,
24
24
  ): Result<{ readonly targetId: string }, ToolErr> => {
25
25
  const current = client.current();
26
- if (current === undefined || current === null) {
26
+ if (current === null) {
27
27
  return err({ kind: "invalid_state", message: "No tab attached." });
28
28
  }
29
29
  if (!client.owns(current.targetId)) {
@@ -54,7 +54,7 @@ export const setHeadersTool = defineBrowserTool({
54
54
  if (!target.success) return target;
55
55
  const invalidName = Object.keys(args.headers).find((name) => !HEADER_NAME.test(name));
56
56
  if (invalidName !== undefined) {
57
- return err({ kind: "invalid_state", message: `Invalid HTTP header name: ${JSON.stringify(invalidName)}` });
57
+ return err({ kind: "invalid_args", message: `Invalid HTTP header name: ${JSON.stringify(invalidName)}` });
58
58
  }
59
59
  const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: args.headers });
60
60
  if (!result.success) return result;
@@ -2,7 +2,7 @@ import { Type } from "typebox";
2
2
  import type { BrowserClient } from "../client";
3
3
  import { type Result, err, ok } from "../util/result";
4
4
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
- import { cdpCall } from "./cdp-call";
5
+ import { cdpCall, fromClient } from "./cdp-call";
6
6
 
7
7
  type HistoryEntry = { readonly id: number; readonly url: string; readonly title: string };
8
8
  type History = { readonly entries: ReadonlyArray<HistoryEntry>; readonly currentIndex: number };
@@ -64,9 +64,9 @@ export const reloadTool = defineBrowserTool({
64
64
  async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
65
65
  const r = await cdpCall(client, "Page.reload", {});
66
66
  if (!r.success) return r;
67
- const info = await client.pageInfo();
68
- if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
69
- if ("dialog" in info.data) {
67
+ const info = fromClient(await client.pageInfo(), "pageInfo");
68
+ if (!info.success) return info;
69
+ if (info.data.kind === "dialog") {
70
70
  return ok({
71
71
  text: `Reloaded. ⚠️ Dialog open: ${info.data.dialog.type}`,
72
72
  details: { dialog: info.data.dialog },
@@ -0,0 +1,35 @@
1
+ import { readFile, stat, writeFile } from "node:fs/promises";
2
+ import type { Stats } from "node:fs";
3
+ import { type Result, err, ok } from "../util/result";
4
+ import type { ToolErr } from "../util/tool";
5
+
6
+ const ioErr = (what: string, path: string, e: unknown): ToolErr => ({
7
+ kind: "io_error",
8
+ message: `${what}: ${path} (${e instanceof Error ? e.message : String(e)})`,
9
+ details: { path },
10
+ });
11
+
12
+ export const writeFileResult = async (path: string, data: Uint8Array | string): Promise<Result<void, ToolErr>> => {
13
+ try {
14
+ await writeFile(path, data);
15
+ return ok(undefined);
16
+ } catch (e) {
17
+ return err(ioErr("Cannot write file", path, e));
18
+ }
19
+ };
20
+
21
+ export const readFileResult = async (path: string): Promise<Result<Buffer, ToolErr>> => {
22
+ try {
23
+ return ok(await readFile(path));
24
+ } catch (e) {
25
+ return err(ioErr("Cannot read file", path, e));
26
+ }
27
+ };
28
+
29
+ export const statResult = async (path: string): Promise<Result<Stats, ToolErr>> => {
30
+ try {
31
+ return ok(await stat(path));
32
+ } catch (e) {
33
+ return err(ioErr("Cannot stat file", path, e));
34
+ }
35
+ };
@@ -1,10 +1,10 @@
1
1
  import type { BrowserClient } from "../client";
2
2
  import { type Result, err, ok } from "../util/result";
3
- import { safeJs } from "../util/js-template";
4
3
  import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
5
4
  import { evaluateJson } from "../cdp/session";
6
5
  import type { ToolErr } from "../util/tool";
7
- import { cdpCallBrowser, cdpCallOnTarget, cdpErrToToolErr, evalJs } from "./cdp-call";
6
+ import { cdpCallBrowser, cdpCallOnTarget, cdpErrToToolErr } from "./cdp-call";
7
+ import { pollUntil, READY_STATE_COMPLETE } from "./poll";
8
8
 
9
9
  export type IsolatedTab = {
10
10
  readonly targetId: string;
@@ -12,7 +12,6 @@ export type IsolatedTab = {
12
12
  };
13
13
 
14
14
  const EVAL_TIMEOUT_MS = 15_000;
15
- const READY_POLL_INTERVAL_MS = 50;
16
15
 
17
16
  export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => {
18
17
  // Routed through the target factory so an isolated tab lands in the pinned profile, not another profile's cookies.
@@ -60,14 +59,9 @@ export const waitForIsolatedLoad = async (
60
59
  timeoutMs: number,
61
60
  signal?: AbortSignal,
62
61
  ): Promise<Result<void, ToolErr>> => {
63
- const start = Date.now();
64
- while (Date.now() - start < timeoutMs) {
65
- if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
66
- const r = await evalJs(client, safeJs`document.readyState`, tab.sessionId);
67
- if (r.success && r.data === "complete") return ok(undefined);
68
- await new Promise((res) => setTimeout(res, READY_POLL_INTERVAL_MS));
69
- }
70
- return err({ kind: "timeout", message: `page did not finish loading in ${Math.round(timeoutMs / 1000)}s` });
62
+ const r = await pollUntil(client, READY_STATE_COMPLETE, { timeoutMs, signal, sessionId: tab.sessionId, what: "page load" });
63
+ if (!r.success) return r;
64
+ return ok(undefined);
71
65
  };
72
66
 
73
67
  export const isJsonText = (v: unknown): v is string => typeof v === "string";
@@ -87,5 +81,5 @@ export const evalInIsolatedTab = async <T>(
87
81
  };
88
82
 
89
83
  export const closeIsolatedTab = async (client: BrowserClient, tab: IsolatedTab): Promise<void> => {
90
- await client.closeTab(tab.targetId).catch(() => {});
84
+ await client.closeTab(tab.targetId);
91
85
  };