pi-browser-harness 0.10.2 → 0.11.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 +28 -0
- package/README.md +5 -9
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/src/domains/form.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import type { BrowserClient } from "../client";
|
|
3
|
-
import { safeJs } from "../util/js-template";
|
|
4
2
|
import { type Result, err, ok } from "../util/result";
|
|
5
3
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
6
|
-
import {
|
|
4
|
+
import { detailsOf, isPageResult, type PageResult, resolveAndCall, runOnElement } from "./element-call";
|
|
5
|
+
import { fillBody } from "./fill-engine";
|
|
6
|
+
import { interactiveDiff } from "./ref-resolve";
|
|
7
7
|
|
|
8
8
|
const FillArgs = Type.Object({
|
|
9
9
|
ref: Type.Optional(
|
|
@@ -16,107 +16,21 @@ const FillArgs = Type.Object({
|
|
|
16
16
|
value: Type.String({ description: "Value to set in the field" }),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
* Run an element-scoped function either against a CSS selector (querySelector +
|
|
21
|
-
* apply) or a ref (resolve to objectId, callFunctionOn with element as `this`).
|
|
22
|
-
* The shared `fnBody` is a function body where `this` is the target element and
|
|
23
|
-
* the single argument is the value — identical logic on both paths. Returns the
|
|
24
|
-
* function's return value (parsed) or a ToolErr.
|
|
25
|
-
*
|
|
26
|
-
* On the ref path a detached node yields a stale-ref error; on the selector path
|
|
27
|
-
* a missing element returns { status: "not_found" } from the function body.
|
|
28
|
-
*/
|
|
29
|
-
const runOnElement = async (
|
|
30
|
-
client: BrowserClient,
|
|
31
|
-
opts: { ref?: string | undefined; selector?: string | undefined; fnBody: string; arg: unknown },
|
|
32
|
-
): Promise<Result<unknown, ToolErr>> => {
|
|
33
|
-
if (opts.ref !== undefined) {
|
|
34
|
-
const objectId = await resolveRefToObjectId(client, opts.ref);
|
|
35
|
-
if (!objectId.success) return objectId;
|
|
36
|
-
const r = await client.session().call("Runtime.callFunctionOn", {
|
|
37
|
-
objectId: objectId.data,
|
|
38
|
-
functionDeclaration: `function (arg) { ${opts.fnBody} }`,
|
|
39
|
-
arguments: [{ value: opts.arg }],
|
|
40
|
-
returnByValue: true,
|
|
41
|
-
});
|
|
42
|
-
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
43
|
-
return ok((r.data as { result?: { value?: unknown } }).result?.value);
|
|
44
|
-
}
|
|
45
|
-
if (opts.selector === undefined) {
|
|
46
|
-
return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
|
|
47
|
-
}
|
|
48
|
-
// Selector path: bind the same trusted function body to the matched element
|
|
49
|
-
// via .call(). fnBody is harness-authored (not user input); only the selector
|
|
50
|
-
// and value are interpolated, and those go through safeJs.
|
|
51
|
-
const prelude = safeJs`
|
|
52
|
-
(() => {
|
|
53
|
-
const el = document.querySelector(${opts.selector});
|
|
54
|
-
if (!el) return { status: "not_found" };
|
|
55
|
-
const __arg = ${opts.arg};
|
|
56
|
-
return (function (arg) {`;
|
|
57
|
-
const expr = `${prelude} ${opts.fnBody} }).call(el, __arg); })()`;
|
|
58
|
-
const r = await client.evaluateJs(expr);
|
|
59
|
-
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
60
|
-
return ok(r.data);
|
|
61
|
-
};
|
|
19
|
+
const FILL_BODY = `const value = arg;\n${fillBody({ rejectSelect: true, focusFirst: false })}`;
|
|
62
20
|
|
|
63
|
-
|
|
64
|
-
* Result shape returned by the in-page fill script. Discriminated by `status`
|
|
65
|
-
* so the handler can map page-level outcomes to typed ToolErr kinds.
|
|
66
|
-
*/
|
|
67
|
-
type FillResult =
|
|
68
|
-
| { status: "ok"; tag: string; kind: string; value: string }
|
|
69
|
-
| { status: "not_found" }
|
|
70
|
-
| { status: "not_fillable"; tag: string };
|
|
71
|
-
|
|
72
|
-
// Element-scoped fill logic. `this` is the target element, `arg` is the value.
|
|
73
|
-
// Write via the native prototype value setter so React's _valueTracker
|
|
74
|
-
// registers the change — assigning el.value directly does NOT, which is why
|
|
75
|
-
// controlled inputs revert on re-render. Shared verbatim by the ref path
|
|
76
|
-
// (callFunctionOn) and the selector path (querySelector + .call).
|
|
77
|
-
const FILL_FN = `
|
|
78
|
-
const el = this;
|
|
79
|
-
const tag = el.tagName;
|
|
80
|
-
const proto =
|
|
81
|
-
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype
|
|
82
|
-
: el instanceof HTMLInputElement ? HTMLInputElement.prototype
|
|
83
|
-
: null;
|
|
84
|
-
if (proto) {
|
|
85
|
-
const desc = Object.getOwnPropertyDescriptor(proto, "value");
|
|
86
|
-
const setter = desc && desc.set;
|
|
87
|
-
if (setter) setter.call(el, arg);
|
|
88
|
-
else el.value = arg;
|
|
89
|
-
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
90
|
-
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
91
|
-
return { status: "ok", tag, kind: el.type || tag.toLowerCase(), value: el.value };
|
|
92
|
-
}
|
|
93
|
-
if (el.isContentEditable) {
|
|
94
|
-
// Rich-text editors (Slack/Notion/ProseMirror) listen for beforeinput/input.
|
|
95
|
-
el.focus();
|
|
96
|
-
let done = false;
|
|
97
|
-
try {
|
|
98
|
-
const sel = window.getSelection();
|
|
99
|
-
if (sel) { sel.removeAllRanges(); const rng = document.createRange(); rng.selectNodeContents(el); sel.addRange(rng); }
|
|
100
|
-
done = document.execCommand("insertText", false, arg);
|
|
101
|
-
} catch (e) { done = false; }
|
|
102
|
-
if (!done) {
|
|
103
|
-
el.textContent = arg;
|
|
104
|
-
el.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
|
105
|
-
}
|
|
106
|
-
return { status: "ok", tag, kind: "contenteditable", value: el.textContent || "" };
|
|
107
|
-
}
|
|
108
|
-
return { status: "not_fillable", tag };
|
|
109
|
-
`;
|
|
21
|
+
const NOT_FOUND: PageResult = { ok: false, reason: "not_found" };
|
|
110
22
|
|
|
111
23
|
export const fillTool = defineBrowserTool({
|
|
112
24
|
name: "browser_fill",
|
|
113
25
|
label: "Browser Fill",
|
|
114
26
|
description:
|
|
115
|
-
"Fill a form field (input/textarea/contenteditable). PREFERRED: pass `ref` (e.g. 'e7') from browser_snapshot — survives re-renders. Fallback: a CSS `selector`. Writes through the native value setter and fires bubbling 'input'/'change' events, so React/Vue/Angular controlled components and rich-text editors update correctly — unlike browser_type. Returns the field's value after writing, plus a compact diff of page changes.",
|
|
27
|
+
"Fill a form field (input/textarea/contenteditable), or tick a checkbox/radio by passing 'true'/'false'. PREFERRED: pass `ref` (e.g. 'e7') from browser_snapshot — survives re-renders. Fallback: a CSS `selector`. Writes through the native value setter and fires bubbling 'input'/'change' events, so React/Vue/Angular controlled components and rich-text editors update correctly — unlike browser_type. Refuses a disabled field rather than writing a value the page will ignore. Returns the field's value after writing, plus a compact diff of page changes.",
|
|
116
28
|
promptSnippet: "Fill a form field by ref (preferred) or selector (works with React/Vue controlled inputs)",
|
|
117
29
|
promptGuidelines: [
|
|
118
30
|
"PREFER `ref` from browser_snapshot (the '[eN]' handle) over a guessed CSS selector — refs survive re-renders and don't require you to invent a selector.",
|
|
119
31
|
"Pass the desired value; no browser_click is needed first.",
|
|
32
|
+
"For a checkbox or radio pass 'true' or 'false' — it toggles `checked`, not the submit value. browser_set_checked does the same thing with a boolean.",
|
|
33
|
+
"A disabled field is refused: the error says so, rather than reporting a write the page never accepted.",
|
|
120
34
|
"A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
|
|
121
35
|
"Use browser_type instead only for keystroke-sensitive widgets (autocomplete, masked/segmented inputs).",
|
|
122
36
|
"Fill sets the value but does not focus or submit. To submit a tag/autocomplete input, follow with browser_dispatch_key({ ref, key: 'Enter' }) — not browser_press_key, which targets the focused element and may miss this field.",
|
|
@@ -124,29 +38,52 @@ export const fillTool = defineBrowserTool({
|
|
|
124
38
|
"The result reports the field's value after writing and an appended page-changes diff — confirm both match what you intended before moving on.",
|
|
125
39
|
],
|
|
126
40
|
parameters: FillArgs,
|
|
41
|
+
concurrency: "serialized",
|
|
127
42
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
128
|
-
const r = await runOnElement(client, {
|
|
43
|
+
const r = await runOnElement(client, {
|
|
44
|
+
ref: args.ref,
|
|
45
|
+
selector: args.selector,
|
|
46
|
+
fnBody: FILL_BODY,
|
|
47
|
+
arg: args.value,
|
|
48
|
+
notFound: NOT_FOUND,
|
|
49
|
+
});
|
|
129
50
|
if (!r.success) return r;
|
|
130
|
-
const res = r.data
|
|
51
|
+
const res = isPageResult(r.data) ? r.data : undefined;
|
|
131
52
|
const target = args.ref ?? args.selector ?? "";
|
|
132
|
-
if (res === undefined || res.
|
|
53
|
+
if (res === undefined || res.reason === "not_found") {
|
|
133
54
|
return err({
|
|
134
55
|
kind: "invalid_state",
|
|
135
56
|
message: `No element matched: ${target}`,
|
|
136
57
|
details: { ref: args.ref, selector: args.selector },
|
|
137
58
|
});
|
|
138
59
|
}
|
|
139
|
-
if (res.
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const details = { ref: args.ref, selector: args.selector, ...(res.tag !== undefined ? { tag: res.tag } : {}) };
|
|
62
|
+
if (res.kind === "select") {
|
|
63
|
+
return err({
|
|
64
|
+
kind: "invalid_state",
|
|
65
|
+
message: `Element <select> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
|
|
66
|
+
details,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
140
69
|
return err({
|
|
141
70
|
kind: "invalid_state",
|
|
142
|
-
message: `
|
|
143
|
-
details
|
|
71
|
+
message: `Could not fill ${target}: ${res.reason ?? "unknown reason"}`,
|
|
72
|
+
details,
|
|
144
73
|
});
|
|
145
74
|
}
|
|
75
|
+
const shown = res.value !== undefined ? JSON.stringify(res.value) : `checked=${res.checked === true}`;
|
|
146
76
|
const diff = await interactiveDiff(client);
|
|
147
77
|
return ok({
|
|
148
|
-
text: `Filled ${target} = ${
|
|
149
|
-
details: {
|
|
78
|
+
text: `Filled ${target} = ${shown}${diff}`,
|
|
79
|
+
details: {
|
|
80
|
+
ref: args.ref,
|
|
81
|
+
selector: args.selector,
|
|
82
|
+
value: args.value,
|
|
83
|
+
verified: res.value ?? res.checked,
|
|
84
|
+
...(res.tag !== undefined ? { tag: res.tag } : {}),
|
|
85
|
+
...(res.kind !== undefined ? { kind: res.kind } : {}),
|
|
86
|
+
},
|
|
150
87
|
});
|
|
151
88
|
},
|
|
152
89
|
});
|
|
@@ -160,6 +97,13 @@ const FocusArgs = Type.Object({
|
|
|
160
97
|
|
|
161
98
|
type FocusResult = { status: "ok"; tag: string } | { status: "not_found" } | { status: "not_focusable"; tag: string };
|
|
162
99
|
|
|
100
|
+
const isFocusResult = (v: unknown): v is FocusResult => {
|
|
101
|
+
if (typeof v !== "object" || v === null || !("status" in v)) return false;
|
|
102
|
+
if (v.status === "not_found") return true;
|
|
103
|
+
if (v.status === "ok" || v.status === "not_focusable") return "tag" in v && typeof v.tag === "string";
|
|
104
|
+
return false;
|
|
105
|
+
};
|
|
106
|
+
|
|
163
107
|
const FOCUS_FN = `
|
|
164
108
|
const el = this;
|
|
165
109
|
if (typeof el.focus !== "function") return { status: "not_focusable", tag: el.tagName };
|
|
@@ -179,10 +123,11 @@ export const focusTool = defineBrowserTool({
|
|
|
179
123
|
"For simply setting a value, prefer browser_fill (it doesn't need a separate focus step).",
|
|
180
124
|
],
|
|
181
125
|
parameters: FocusArgs,
|
|
126
|
+
concurrency: "serialized",
|
|
182
127
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
183
128
|
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FOCUS_FN, arg: "" });
|
|
184
129
|
if (!r.success) return r;
|
|
185
|
-
const res = r.data
|
|
130
|
+
const res = isFocusResult(r.data) ? r.data : undefined;
|
|
186
131
|
const target = args.ref ?? args.selector ?? "";
|
|
187
132
|
if (res === undefined || res.status === "not_found") {
|
|
188
133
|
return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
|
|
@@ -210,7 +155,24 @@ type SelectResult =
|
|
|
210
155
|
| { status: "not_select"; tag: string }
|
|
211
156
|
| { status: "no_match"; options: ReadonlyArray<{ value: string; label: string }> };
|
|
212
157
|
|
|
213
|
-
|
|
158
|
+
const isSelectOption = (v: unknown): v is { value: string; label: string } =>
|
|
159
|
+
typeof v === "object" && v !== null
|
|
160
|
+
&& "value" in v && typeof v.value === "string"
|
|
161
|
+
&& "label" in v && typeof v.label === "string";
|
|
162
|
+
|
|
163
|
+
const isSelectResult = (v: unknown): v is SelectResult => {
|
|
164
|
+
if (typeof v !== "object" || v === null || !("status" in v)) return false;
|
|
165
|
+
if (v.status === "not_found") return true;
|
|
166
|
+
if (v.status === "not_select") return "tag" in v && typeof v.tag === "string";
|
|
167
|
+
if (v.status === "ok") {
|
|
168
|
+
return "value" in v && typeof v.value === "string" && "label" in v && typeof v.label === "string";
|
|
169
|
+
}
|
|
170
|
+
if (v.status === "no_match") {
|
|
171
|
+
return "options" in v && Array.isArray(v.options) && v.options.every(isSelectOption);
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
};
|
|
175
|
+
|
|
214
176
|
const SELECT_FN = `
|
|
215
177
|
const el = this;
|
|
216
178
|
if (el.tagName !== "SELECT") return { status: "not_select", tag: el.tagName };
|
|
@@ -240,6 +202,7 @@ export const selectOptionTool = defineBrowserTool({
|
|
|
240
202
|
"If no option matches, the error details list the available options so you can retry.",
|
|
241
203
|
],
|
|
242
204
|
parameters: SelectOptionArgs,
|
|
205
|
+
concurrency: "serialized",
|
|
243
206
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
244
207
|
if (args.value === undefined && args.label === undefined && args.index === undefined) {
|
|
245
208
|
return err({ kind: "invalid_state", message: "Provide one of value, label, or index." });
|
|
@@ -248,7 +211,7 @@ export const selectOptionTool = defineBrowserTool({
|
|
|
248
211
|
const arg = { wv: args.value ?? null, wl: args.label ?? null, wi: args.index ?? null };
|
|
249
212
|
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: SELECT_FN, arg });
|
|
250
213
|
if (!r.success) return r;
|
|
251
|
-
const res = r.data
|
|
214
|
+
const res = isSelectResult(r.data) ? r.data : undefined;
|
|
252
215
|
const target = args.ref ?? args.selector ?? "";
|
|
253
216
|
if (res === undefined || res.status === "not_found") {
|
|
254
217
|
return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
|
|
@@ -270,3 +233,51 @@ export const selectOptionTool = defineBrowserTool({
|
|
|
270
233
|
});
|
|
271
234
|
},
|
|
272
235
|
});
|
|
236
|
+
|
|
237
|
+
const CHECK_FN = `
|
|
238
|
+
function(checked) {
|
|
239
|
+
const el = this;
|
|
240
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
241
|
+
const type = (el.type || "").toLowerCase();
|
|
242
|
+
if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" };
|
|
243
|
+
if (el.disabled) return { ok: false, reason: "element is disabled" };
|
|
244
|
+
const want = checked === true;
|
|
245
|
+
let changed = false;
|
|
246
|
+
if (el.checked !== want) {
|
|
247
|
+
try { el.click(); } catch (e) {}
|
|
248
|
+
if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); }
|
|
249
|
+
changed = true;
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, checked: el.checked, changed: changed };
|
|
252
|
+
}`;
|
|
253
|
+
|
|
254
|
+
const SetCheckedArgs = Type.Object({
|
|
255
|
+
ref: Type.String({ description: "Stable element ref of a checkbox or radio from browser_snapshot (e.g. 'e7')." }),
|
|
256
|
+
checked: Type.Boolean({ description: "Desired checked state." }),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
export const setCheckedTool = defineBrowserTool({
|
|
260
|
+
name: "browser_set_checked",
|
|
261
|
+
label: "Browser Set Checked",
|
|
262
|
+
description:
|
|
263
|
+
"Set a checkbox or radio to a desired checked state by ref. Only acts if the current state differs, fires change, and reports the final state. Get the ref from browser_snapshot's [eN].",
|
|
264
|
+
promptSnippet: "Set a checkbox/radio checked state by ref",
|
|
265
|
+
promptGuidelines: [
|
|
266
|
+
"checked:true ticks the box, checked:false unticks it. Idempotent — no-op if already in the desired state.",
|
|
267
|
+
"For radios, set checked:true on the option you want; the group deselects the others.",
|
|
268
|
+
],
|
|
269
|
+
parameters: SetCheckedArgs,
|
|
270
|
+
concurrency: "serialized",
|
|
271
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
272
|
+
const r = await resolveAndCall(client, args.ref, CHECK_FN, [args.checked]);
|
|
273
|
+
if (!r.success) return r;
|
|
274
|
+
const res = r.data;
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
|
|
277
|
+
}
|
|
278
|
+
return ok({
|
|
279
|
+
text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed === false ? " (already)" : ""),
|
|
280
|
+
details: detailsOf(args.ref, res),
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
});
|
package/src/domains/history.ts
CHANGED
|
@@ -2,14 +2,15 @@ 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
6
|
|
|
6
7
|
type HistoryEntry = { readonly id: number; readonly url: string; readonly title: string };
|
|
7
8
|
type History = { readonly entries: ReadonlyArray<HistoryEntry>; readonly currentIndex: number };
|
|
8
9
|
|
|
9
10
|
const fetchHistory = async (client: BrowserClient): Promise<Result<History, ToolErr>> => {
|
|
10
|
-
const r = await client
|
|
11
|
-
if (!r.success) return
|
|
12
|
-
return ok(r.data
|
|
11
|
+
const r = await cdpCall(client, "Page.getNavigationHistory", {});
|
|
12
|
+
if (!r.success) return r;
|
|
13
|
+
return ok(r.data);
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
export const goBackTool = defineBrowserTool({
|
|
@@ -19,14 +20,15 @@ export const goBackTool = defineBrowserTool({
|
|
|
19
20
|
promptSnippet: "Go back one page",
|
|
20
21
|
promptGuidelines: ["Use to return to the previous page after navigating."],
|
|
21
22
|
parameters: Type.Object({}),
|
|
23
|
+
concurrency: "serialized",
|
|
22
24
|
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
23
25
|
const h = await fetchHistory(client);
|
|
24
26
|
if (!h.success) return h;
|
|
25
27
|
if (h.data.currentIndex <= 0) return ok({ text: "Already at the beginning of history." });
|
|
26
28
|
const target = h.data.entries[h.data.currentIndex - 1];
|
|
27
29
|
if (!target) return err({ kind: "internal", message: "history entry missing at index" });
|
|
28
|
-
const nav = await client
|
|
29
|
-
if (!nav.success) return
|
|
30
|
+
const nav = await cdpCall(client, "Page.navigateToHistoryEntry", { entryId: target.id });
|
|
31
|
+
if (!nav.success) return nav;
|
|
30
32
|
return ok({ text: `Navigated back to: ${target.url}`, details: { url: target.url } });
|
|
31
33
|
},
|
|
32
34
|
});
|
|
@@ -38,14 +40,15 @@ export const goForwardTool = defineBrowserTool({
|
|
|
38
40
|
promptSnippet: "Go forward one page",
|
|
39
41
|
promptGuidelines: ["Use to undo a browser_go_back."],
|
|
40
42
|
parameters: Type.Object({}),
|
|
43
|
+
concurrency: "serialized",
|
|
41
44
|
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
42
45
|
const h = await fetchHistory(client);
|
|
43
46
|
if (!h.success) return h;
|
|
44
47
|
if (h.data.currentIndex >= h.data.entries.length - 1) return ok({ text: "Already at the end of history." });
|
|
45
48
|
const target = h.data.entries[h.data.currentIndex + 1];
|
|
46
49
|
if (!target) return err({ kind: "internal", message: "history entry missing at index" });
|
|
47
|
-
const nav = await client
|
|
48
|
-
if (!nav.success) return
|
|
50
|
+
const nav = await cdpCall(client, "Page.navigateToHistoryEntry", { entryId: target.id });
|
|
51
|
+
if (!nav.success) return nav;
|
|
49
52
|
return ok({ text: `Navigated forward to: ${target.url}`, details: { url: target.url } });
|
|
50
53
|
},
|
|
51
54
|
});
|
|
@@ -57,9 +60,10 @@ export const reloadTool = defineBrowserTool({
|
|
|
57
60
|
promptSnippet: "Reload the page",
|
|
58
61
|
promptGuidelines: ["Use to refresh the page, e.g. after server-side changes."],
|
|
59
62
|
parameters: Type.Object({}),
|
|
63
|
+
concurrency: "serialized",
|
|
60
64
|
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
61
|
-
const r = await client
|
|
62
|
-
if (!r.success) return
|
|
65
|
+
const r = await cdpCall(client, "Page.reload", {});
|
|
66
|
+
if (!r.success) return r;
|
|
63
67
|
const info = await client.pageInfo();
|
|
64
68
|
if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
|
|
65
69
|
if ("dialog" in info.data) {
|
|
@@ -1,22 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Isolated-attach tab lifecycle (ADR-0002 v1). Opens a harness-owned tab,
|
|
3
|
-
* attaches to it by its own CDP sessionId, and drives it via callOnTarget /
|
|
4
|
-
* evaluateJs(expr, sid) WITHOUT ever touching the global "current" tab — so a
|
|
5
|
-
* search or page-read never disturbs the tab the user is looking at, even when
|
|
6
|
-
* several subagents run concurrently.
|
|
7
|
-
*
|
|
8
|
-
* Mirrors the create/attach sequence in browser_open_urls
|
|
9
|
-
* (src/domains/navigate.ts:84-139). Every page evaluation is bounded by a
|
|
10
|
-
* timeout, so a JS dialog on the (uninstrumented) isolated tab surfaces as a
|
|
11
|
-
* typed timeout rather than hanging — see the dialog-hang caveat in plan.md.
|
|
12
|
-
*/
|
|
13
1
|
import type { BrowserClient } from "../client";
|
|
14
2
|
import { type Result, err, ok } from "../util/result";
|
|
15
3
|
import { safeJs } from "../util/js-template";
|
|
16
4
|
import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
|
|
5
|
+
import { evaluateJson } from "../cdp/session";
|
|
17
6
|
import type { ToolErr } from "../util/tool";
|
|
7
|
+
import { cdpCallBrowser, cdpCallOnTarget, cdpErrToToolErr, evalJs } from "./cdp-call";
|
|
18
8
|
|
|
19
|
-
/** A tab attached by its own sessionId, ready to drive via callOnTarget. */
|
|
20
9
|
export type IsolatedTab = {
|
|
21
10
|
readonly targetId: string;
|
|
22
11
|
readonly sessionId: string;
|
|
@@ -25,56 +14,46 @@ export type IsolatedTab = {
|
|
|
25
14
|
const EVAL_TIMEOUT_MS = 15_000;
|
|
26
15
|
const READY_POLL_INTERVAL_MS = 50;
|
|
27
16
|
|
|
28
|
-
const toToolErr = (message: string, kind: ToolErr["kind"] = "cdp_error"): ToolErr => ({ kind, message });
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Open a harness-owned tab and attach to it, returning its targetId + private
|
|
32
|
-
* sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done.
|
|
33
|
-
*/
|
|
34
17
|
export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => {
|
|
35
|
-
// Routed through the target factory so an isolated tab lands in the pinned
|
|
36
|
-
// profile like every other harness tab — otherwise a search or page read
|
|
37
|
-
// would run with a different profile's cookies than the visible tabs.
|
|
18
|
+
// Routed through the target factory so an isolated tab lands in the pinned profile, not another profile's cookies.
|
|
38
19
|
const window = await ensureHarnessWindow(client);
|
|
39
|
-
if (!window.success) return err(
|
|
20
|
+
if (!window.success) return err(cdpErrToToolErr(window.error, "Target.createTarget"));
|
|
40
21
|
let targetId: string;
|
|
41
22
|
if (window.data.freshlyCreated) {
|
|
42
23
|
targetId = window.data.targetId;
|
|
43
24
|
} else {
|
|
44
25
|
const opened = await openHarnessTab(client, window.data.targetId);
|
|
45
|
-
if (!opened.success) return err(
|
|
26
|
+
if (!opened.success) return err(cdpErrToToolErr(opened.error, "Target.createTarget"));
|
|
46
27
|
targetId = opened.data;
|
|
47
28
|
}
|
|
48
29
|
|
|
49
|
-
const attached = await client
|
|
30
|
+
const attached = await cdpCallBrowser(client, "Target.attachToTarget", { targetId, flatten: true });
|
|
50
31
|
if (!attached.success) {
|
|
51
32
|
await client.closeTab(targetId);
|
|
52
|
-
return
|
|
33
|
+
return attached;
|
|
53
34
|
}
|
|
54
|
-
const { sessionId } = attached.data
|
|
35
|
+
const { sessionId } = attached.data;
|
|
55
36
|
|
|
56
|
-
const enabled = await client
|
|
37
|
+
const enabled = await cdpCallOnTarget(client, "Page.enable", {}, sessionId);
|
|
57
38
|
if (!enabled.success) {
|
|
58
39
|
await client.closeTab(targetId);
|
|
59
|
-
return
|
|
40
|
+
return enabled;
|
|
60
41
|
}
|
|
61
42
|
return ok({ targetId, sessionId });
|
|
62
43
|
};
|
|
63
44
|
|
|
64
|
-
/** Navigate an isolated tab to a URL (does not wait for load). */
|
|
65
45
|
export const navigateIsolatedTab = async (
|
|
66
46
|
client: BrowserClient,
|
|
67
47
|
tab: IsolatedTab,
|
|
68
48
|
url: string,
|
|
69
49
|
): Promise<Result<void, ToolErr>> => {
|
|
70
|
-
const nav = await client
|
|
50
|
+
const nav = await cdpCallOnTarget(client, "Page.navigate", { url }, tab.sessionId, {
|
|
71
51
|
timeoutMs: EVAL_TIMEOUT_MS,
|
|
72
52
|
});
|
|
73
|
-
if (!nav.success) return
|
|
53
|
+
if (!nav.success) return nav;
|
|
74
54
|
return ok(undefined);
|
|
75
55
|
};
|
|
76
56
|
|
|
77
|
-
/** Poll the isolated tab until document.readyState === 'complete' or timeout. */
|
|
78
57
|
export const waitForIsolatedLoad = async (
|
|
79
58
|
client: BrowserClient,
|
|
80
59
|
tab: IsolatedTab,
|
|
@@ -83,38 +62,30 @@ export const waitForIsolatedLoad = async (
|
|
|
83
62
|
): Promise<Result<void, ToolErr>> => {
|
|
84
63
|
const start = Date.now();
|
|
85
64
|
while (Date.now() - start < timeoutMs) {
|
|
86
|
-
if (signal?.aborted) return err(
|
|
87
|
-
const r = await client
|
|
65
|
+
if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
|
|
66
|
+
const r = await evalJs(client, safeJs`document.readyState`, tab.sessionId);
|
|
88
67
|
if (r.success && r.data === "complete") return ok(undefined);
|
|
89
68
|
await new Promise((res) => setTimeout(res, READY_POLL_INTERVAL_MS));
|
|
90
69
|
}
|
|
91
|
-
return err(
|
|
70
|
+
return err({ kind: "timeout", message: `page did not finish loading in ${Math.round(timeoutMs / 1000)}s` });
|
|
92
71
|
};
|
|
93
72
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
*/
|
|
98
|
-
export const evalInIsolatedTab = async (
|
|
73
|
+
export const isJsonText = (v: unknown): v is string => typeof v === "string";
|
|
74
|
+
|
|
75
|
+
export const evalInIsolatedTab = async <T>(
|
|
99
76
|
client: BrowserClient,
|
|
100
77
|
tab: IsolatedTab,
|
|
101
78
|
expression: string,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
const data = r.data as { result?: { value?: unknown }; exceptionDetails?: unknown };
|
|
111
|
-
if (data.exceptionDetails) {
|
|
112
|
-
return err(toToolErr(`page evaluation failed: ${JSON.stringify(data.exceptionDetails)}`));
|
|
113
|
-
}
|
|
114
|
-
return ok(data.result?.value);
|
|
79
|
+
check: (v: unknown) => v is T,
|
|
80
|
+
): Promise<Result<T, ToolErr>> => {
|
|
81
|
+
const r = await evaluateJson(client.session(), expression, check, {
|
|
82
|
+
sessionId: tab.sessionId,
|
|
83
|
+
timeoutMs: EVAL_TIMEOUT_MS,
|
|
84
|
+
});
|
|
85
|
+
if (!r.success) return err(cdpErrToToolErr(r.error, "Runtime.evaluate"));
|
|
86
|
+
return ok(r.data);
|
|
115
87
|
};
|
|
116
88
|
|
|
117
|
-
/** Close an isolated tab and drop its ownership. Best-effort; never throws. */
|
|
118
89
|
export const closeIsolatedTab = async (client: BrowserClient, tab: IsolatedTab): Promise<void> => {
|
|
119
90
|
await client.closeTab(tab.targetId).catch(() => {});
|
|
120
91
|
};
|