pi-feats 0.1.2 → 0.1.4
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/README.md +24 -1
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/pi-feats-preview.png +0 -0
- package/extensions/api-server/application-log-store.ts +31 -0
- package/extensions/api-server/application-runtime.ts +80 -18
- package/extensions/api-server/application-store.ts +6 -2
- package/extensions/api-server/profile-store.ts +47 -20
- package/extensions/api-server/server.ts +182 -47
- package/extensions/browser-harness/cdp/commands.ts +260 -0
- package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
- package/extensions/browser-harness/cdp/discovery.ts +198 -0
- package/extensions/browser-harness/cdp/errors.ts +23 -0
- package/extensions/browser-harness/cdp/events.ts +170 -0
- package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
- package/extensions/browser-harness/cdp/ownership.ts +73 -0
- package/extensions/browser-harness/cdp/record-store.ts +61 -0
- package/extensions/browser-harness/cdp/session.ts +403 -0
- package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
- package/extensions/browser-harness/cdp/target-factory.ts +153 -0
- package/extensions/browser-harness/cdp/types.ts +82 -0
- package/extensions/browser-harness/client.ts +366 -0
- package/extensions/browser-harness/daemon/bridge.ts +531 -0
- package/extensions/browser-harness/daemon/index.ts +86 -0
- package/extensions/browser-harness/daemon/protocol.ts +110 -0
- package/extensions/browser-harness/daemon/queue.ts +85 -0
- package/extensions/browser-harness/daemon/server.ts +170 -0
- package/extensions/browser-harness/daemon/spawn.ts +124 -0
- package/extensions/browser-harness/daemon/transport.ts +178 -0
- package/extensions/browser-harness/domains/ax-tree.ts +179 -0
- package/extensions/browser-harness/domains/box.ts +51 -0
- package/extensions/browser-harness/domains/cdp-call.ts +55 -0
- package/extensions/browser-harness/domains/click.ts +97 -0
- package/extensions/browser-harness/domains/console.ts +219 -0
- package/extensions/browser-harness/domains/dialog.ts +34 -0
- package/extensions/browser-harness/domains/drag.ts +67 -0
- package/extensions/browser-harness/domains/element-call.ts +117 -0
- package/extensions/browser-harness/domains/files.ts +219 -0
- package/extensions/browser-harness/domains/fill-engine.ts +80 -0
- package/extensions/browser-harness/domains/form-batch.ts +65 -0
- package/extensions/browser-harness/domains/form.ts +283 -0
- package/extensions/browser-harness/domains/headers.ts +89 -0
- package/extensions/browser-harness/domains/history.ts +80 -0
- package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
- package/extensions/browser-harness/domains/js.ts +241 -0
- package/extensions/browser-harness/domains/keyboard.ts +197 -0
- package/extensions/browser-harness/domains/navigate.ts +161 -0
- package/extensions/browser-harness/domains/network.ts +301 -0
- package/extensions/browser-harness/domains/page.ts +154 -0
- package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
- package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
- package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
- package/extensions/browser-harness/domains/render.ts +43 -0
- package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
- package/extensions/browser-harness/domains/screenshot.ts +76 -0
- package/extensions/browser-harness/domains/scroll.ts +59 -0
- package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
- package/extensions/browser-harness/domains/search/web-search.ts +144 -0
- package/extensions/browser-harness/domains/setup.ts +35 -0
- package/extensions/browser-harness/domains/snapshot.ts +256 -0
- package/extensions/browser-harness/domains/tabs.ts +202 -0
- package/extensions/browser-harness/domains/target.ts +35 -0
- package/extensions/browser-harness/domains/viewport.ts +42 -0
- package/extensions/browser-harness/index.ts +88 -0
- package/extensions/browser-harness/profile/bind.ts +90 -0
- package/extensions/browser-harness/profile/browser-process.ts +320 -0
- package/extensions/browser-harness/profile/command.ts +189 -0
- package/extensions/browser-harness/profile/launch.ts +80 -0
- package/extensions/browser-harness/profile/list.ts +131 -0
- package/extensions/browser-harness/profile/paths.ts +103 -0
- package/extensions/browser-harness/profile/store.ts +84 -0
- package/extensions/browser-harness/prompt.ts +16 -0
- package/extensions/browser-harness/registry.ts +80 -0
- package/extensions/browser-harness/schemas/common.ts +11 -0
- package/extensions/browser-harness/schemas/parse.ts +23 -0
- package/extensions/browser-harness/setup.ts +47 -0
- package/extensions/browser-harness/state.ts +51 -0
- package/extensions/browser-harness/steel.ts +215 -0
- package/extensions/browser-harness/util/debug.ts +8 -0
- package/extensions/browser-harness/util/guards.ts +30 -0
- package/extensions/browser-harness/util/js-template.ts +12 -0
- package/extensions/browser-harness/util/mutex.ts +20 -0
- package/extensions/browser-harness/util/paths.ts +31 -0
- package/extensions/browser-harness/util/result.ts +9 -0
- package/extensions/browser-harness/util/sharp-shim.ts +42 -0
- package/extensions/browser-harness/util/tool.ts +185 -0
- package/extensions/browser-harness/util/truncate.ts +41 -0
- package/extensions/cli-resources.ts +35 -16
- package/extensions/composio/index.ts +163 -0
- package/extensions/context-memory/index.ts +58 -0
- package/extensions/lib/application-context.ts +22 -0
- package/extensions/lib/application-handler-templates.ts +17 -1
- package/extensions/lib/context-memory.ts +165 -0
- package/extensions/lib/profile-sandbox.ts +3 -0
- package/extensions/lib/workflow-harness.ts +238 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
- package/extensions/pi-console-webui/app/layout.tsx +6 -1
- package/extensions/pi-console-webui/app/login/page.tsx +5 -9
- package/extensions/pi-console-webui/app/not-found.tsx +5 -0
- package/extensions/pi-console-webui/app/page.tsx +6 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
- package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
- package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
- package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
- package/extensions/pi-console-webui/components/console-page.tsx +3 -1
- package/extensions/pi-console-webui/components/console.tsx +53 -7
- package/extensions/pi-console-webui/components/login-form.tsx +14 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
- package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
- package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
- package/extensions/profiles.ts +18 -7
- package/extensions/pulse/store.ts +5 -2
- package/extensions/sequential-workflow.ts +340 -256
- package/extensions/skill-sources/store.ts +24 -3
- package/licenses/pi-browser-harness-MIT.txt +21 -0
- package/package.json +25 -4
- package/schemas/sequential-workflow-template.schema.json +62 -0
- package/skills/browser-harness/SKILL.md +33 -0
- package/skills/composio-connections/SKILL.md +75 -0
- package/skills/sequential-workflow/SKILL.md +70 -0
- package/skills/sequential-workflow-template/SKILL.md +44 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
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";
|
|
6
|
+
import { interactiveDiff } from "./ref-resolve";
|
|
7
|
+
|
|
8
|
+
const FillArgs = Type.Object({
|
|
9
|
+
ref: Type.Optional(
|
|
10
|
+
Type.String({
|
|
11
|
+
description:
|
|
12
|
+
"Stable element ref from browser_snapshot (e.g. 'e7'). PREFERRED over selector — survives re-renders. When set, selector is ignored.",
|
|
13
|
+
}),
|
|
14
|
+
),
|
|
15
|
+
selector: Type.Optional(Type.String({ description: "CSS selector of the form field to fill (fallback when no ref)" })),
|
|
16
|
+
value: Type.String({ description: "Value to set in the field" }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const FILL_BODY = `const value = arg;\n${fillBody({ rejectSelect: true, focusFirst: false })}`;
|
|
20
|
+
|
|
21
|
+
const NOT_FOUND: PageResult = { ok: false, reason: "not_found" };
|
|
22
|
+
|
|
23
|
+
export const fillTool = defineBrowserTool({
|
|
24
|
+
name: "browser_fill",
|
|
25
|
+
label: "Browser Fill",
|
|
26
|
+
description:
|
|
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.",
|
|
28
|
+
promptSnippet: "Fill a form field by ref (preferred) or selector (works with React/Vue controlled inputs)",
|
|
29
|
+
promptGuidelines: [
|
|
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.",
|
|
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.",
|
|
34
|
+
"A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
|
|
35
|
+
"Use browser_type instead only for keystroke-sensitive widgets (autocomplete, masked/segmented inputs).",
|
|
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.",
|
|
37
|
+
"For a custom (div-based) dropdown, browser_fill won't validate against its option list — open it, run browser_snapshot, then browser_click the option's ref.",
|
|
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.",
|
|
39
|
+
],
|
|
40
|
+
parameters: FillArgs,
|
|
41
|
+
concurrency: "serialized",
|
|
42
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
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
|
+
});
|
|
50
|
+
if (!r.success) return r;
|
|
51
|
+
const res = isPageResult(r.data) ? r.data : undefined;
|
|
52
|
+
const target = args.ref ?? args.selector ?? "";
|
|
53
|
+
if (res === undefined || res.reason === "not_found") {
|
|
54
|
+
return err({
|
|
55
|
+
kind: "invalid_state",
|
|
56
|
+
message: `No element matched: ${target}`,
|
|
57
|
+
details: { ref: args.ref, selector: args.selector },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
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
|
+
}
|
|
69
|
+
return err({
|
|
70
|
+
kind: "invalid_state",
|
|
71
|
+
message: `Could not fill ${target}: ${res.reason ?? "unknown reason"}`,
|
|
72
|
+
details,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const shown = res.value !== undefined ? JSON.stringify(res.value) : `checked=${res.checked === true}`;
|
|
76
|
+
const diff = await interactiveDiff(client);
|
|
77
|
+
return ok({
|
|
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
|
+
},
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const FocusArgs = Type.Object({
|
|
92
|
+
ref: Type.Optional(
|
|
93
|
+
Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7'). PREFERRED over selector." }),
|
|
94
|
+
),
|
|
95
|
+
selector: Type.Optional(Type.String({ description: "CSS selector of the element to focus (fallback when no ref)" })),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
type FocusResult = { status: "ok"; tag: string } | { status: "not_found" } | { status: "not_focusable"; tag: string };
|
|
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
|
+
|
|
107
|
+
const FOCUS_FN = `
|
|
108
|
+
const el = this;
|
|
109
|
+
if (typeof el.focus !== "function") return { status: "not_focusable", tag: el.tagName };
|
|
110
|
+
el.focus();
|
|
111
|
+
return document.activeElement === el ? { status: "ok", tag: el.tagName } : { status: "not_focusable", tag: el.tagName };
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
export const focusTool = defineBrowserTool({
|
|
115
|
+
name: "browser_focus",
|
|
116
|
+
label: "Browser Focus",
|
|
117
|
+
description:
|
|
118
|
+
"Focus an element via the DOM .focus() method — deterministic, no coordinate accuracy needed. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`. Use before browser_type when a click might miss the field.",
|
|
119
|
+
promptSnippet: "Focus an element by ref (preferred) or selector",
|
|
120
|
+
promptGuidelines: [
|
|
121
|
+
"PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
|
|
122
|
+
"Use before browser_type to guarantee the right field is focused without relying on click coordinates.",
|
|
123
|
+
"For simply setting a value, prefer browser_fill (it doesn't need a separate focus step).",
|
|
124
|
+
],
|
|
125
|
+
parameters: FocusArgs,
|
|
126
|
+
concurrency: "serialized",
|
|
127
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
128
|
+
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FOCUS_FN, arg: "" });
|
|
129
|
+
if (!r.success) return r;
|
|
130
|
+
const res = isFocusResult(r.data) ? r.data : undefined;
|
|
131
|
+
const target = args.ref ?? args.selector ?? "";
|
|
132
|
+
if (res === undefined || res.status === "not_found") {
|
|
133
|
+
return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
|
|
134
|
+
}
|
|
135
|
+
if (res.status === "not_focusable") {
|
|
136
|
+
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
|
+
}
|
|
138
|
+
return ok({ text: `Focused ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const SelectOptionArgs = Type.Object({
|
|
143
|
+
ref: Type.Optional(
|
|
144
|
+
Type.String({ description: "Stable element ref of the <select> from browser_snapshot. PREFERRED over selector." }),
|
|
145
|
+
),
|
|
146
|
+
selector: Type.Optional(Type.String({ description: "CSS selector of the <select> element (fallback when no ref)" })),
|
|
147
|
+
value: Type.Optional(Type.String({ description: "Option value attribute to select" })),
|
|
148
|
+
label: Type.Optional(Type.String({ description: "Visible option text to select (exact match)" })),
|
|
149
|
+
index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based option index to select" })),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
type SelectResult =
|
|
153
|
+
| { status: "ok"; value: string; label: string }
|
|
154
|
+
| { status: "not_found" }
|
|
155
|
+
| { status: "not_select"; tag: string }
|
|
156
|
+
| { status: "no_match"; options: ReadonlyArray<{ value: string; label: string }> };
|
|
157
|
+
|
|
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
|
+
|
|
176
|
+
const SELECT_FN = `
|
|
177
|
+
const el = this;
|
|
178
|
+
if (el.tagName !== "SELECT") return { status: "not_select", tag: el.tagName };
|
|
179
|
+
const opts = Array.from(el.options);
|
|
180
|
+
const wv = arg.wv, wl = arg.wl, wi = arg.wi;
|
|
181
|
+
let opt = null;
|
|
182
|
+
if (wi !== null) opt = opts[wi] || null;
|
|
183
|
+
else if (wv !== null) opt = opts.find(o => o.value === wv) || null;
|
|
184
|
+
else if (wl !== null) opt = opts.find(o => o.text === wl) || null;
|
|
185
|
+
if (!opt) return { status: "no_match", options: opts.map(o => ({ value: o.value, label: o.text })) };
|
|
186
|
+
el.value = opt.value;
|
|
187
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
188
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
189
|
+
return { status: "ok", value: opt.value, label: opt.text };
|
|
190
|
+
`;
|
|
191
|
+
|
|
192
|
+
export const selectOptionTool = defineBrowserTool({
|
|
193
|
+
name: "browser_select_option",
|
|
194
|
+
label: "Browser Select Option",
|
|
195
|
+
description:
|
|
196
|
+
"Select an option in a native <select> element by value, visible label, or index. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`. Sets the selection and fires bubbling 'input'/'change' events so framework listeners update. On no match, returns the available options.",
|
|
197
|
+
promptSnippet: "Select an option in a native <select> by ref (preferred) or selector",
|
|
198
|
+
promptGuidelines: [
|
|
199
|
+
"PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
|
|
200
|
+
"Provide exactly one of value, label, or index to choose the option.",
|
|
201
|
+
"For custom (non-native) dropdowns built from divs, click to open then browser_click the option instead.",
|
|
202
|
+
"If no option matches, the error details list the available options so you can retry.",
|
|
203
|
+
],
|
|
204
|
+
parameters: SelectOptionArgs,
|
|
205
|
+
concurrency: "serialized",
|
|
206
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
207
|
+
if (args.value === undefined && args.label === undefined && args.index === undefined) {
|
|
208
|
+
return err({ kind: "invalid_state", message: "Provide one of value, label, or index." });
|
|
209
|
+
}
|
|
210
|
+
// null sentinels distinguish "not provided" from a real empty-string match.
|
|
211
|
+
const arg = { wv: args.value ?? null, wl: args.label ?? null, wi: args.index ?? null };
|
|
212
|
+
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: SELECT_FN, arg });
|
|
213
|
+
if (!r.success) return r;
|
|
214
|
+
const res = isSelectResult(r.data) ? r.data : undefined;
|
|
215
|
+
const target = args.ref ?? args.selector ?? "";
|
|
216
|
+
if (res === undefined || res.status === "not_found") {
|
|
217
|
+
return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
|
|
218
|
+
}
|
|
219
|
+
if (res.status === "not_select") {
|
|
220
|
+
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 } });
|
|
221
|
+
}
|
|
222
|
+
if (res.status === "no_match") {
|
|
223
|
+
return err({
|
|
224
|
+
kind: "invalid_state",
|
|
225
|
+
message: `No matching option in ${target}. Available: ${res.options.map((o) => o.label).join(", ")}`,
|
|
226
|
+
details: { ref: args.ref, selector: args.selector, options: res.options },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const diff = await interactiveDiff(client);
|
|
230
|
+
return ok({
|
|
231
|
+
text: `Selected "${res.label}" (value=${JSON.stringify(res.value)}) in ${target}${diff}`,
|
|
232
|
+
details: { ref: args.ref, selector: args.selector, value: res.value, label: res.label },
|
|
233
|
+
});
|
|
234
|
+
},
|
|
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
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { BrowserClient } from "../client";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
5
|
+
import { cdpCall } from "./cdp-call";
|
|
6
|
+
|
|
7
|
+
const HeaderValue = Type.String({
|
|
8
|
+
maxLength: 8192,
|
|
9
|
+
pattern: "^[^\\r\\n]*$",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const SetHeadersArgs = Type.Object({
|
|
13
|
+
headers: Type.Record(Type.String(), HeaderValue, {
|
|
14
|
+
minProperties: 1,
|
|
15
|
+
maxProperties: 64,
|
|
16
|
+
description: "Extra HTTP request headers for the current owned tab. Names must be valid HTTP tokens.",
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
21
|
+
|
|
22
|
+
const currentOwnedTarget = (
|
|
23
|
+
client: BrowserClient,
|
|
24
|
+
): Result<{ readonly targetId: string }, ToolErr> => {
|
|
25
|
+
const current = client.current();
|
|
26
|
+
if (current === undefined || current === null) {
|
|
27
|
+
return err({ kind: "invalid_state", message: "No tab attached." });
|
|
28
|
+
}
|
|
29
|
+
if (!client.owns(current.targetId)) {
|
|
30
|
+
return err({
|
|
31
|
+
kind: "invalid_state",
|
|
32
|
+
message: `Current tab ${current.targetId} is not owned by this harness session.`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return ok({ targetId: current.targetId });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const setHeadersTool = defineBrowserTool({
|
|
39
|
+
name: "browser_set_headers",
|
|
40
|
+
label: "Browser Set Headers",
|
|
41
|
+
description:
|
|
42
|
+
"Set extra HTTP request headers on the current owned tab. Headers persist for subsequent document, subresource, fetch, and XHR requests until cleared or the tab closes.",
|
|
43
|
+
promptSnippet: "Set extra request headers on the current owned tab",
|
|
44
|
+
promptGuidelines: [
|
|
45
|
+
"If no tab is attached yet, call browser_new_tab without a URL first.",
|
|
46
|
+
"Call before navigation when a site requires routing or environment headers.",
|
|
47
|
+
"Call browser_clear_headers before switching tabs or navigating this tab to another origin.",
|
|
48
|
+
"Header values are never echoed in tool output.",
|
|
49
|
+
],
|
|
50
|
+
parameters: SetHeadersArgs,
|
|
51
|
+
concurrency: "serialized",
|
|
52
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
53
|
+
const target = currentOwnedTarget(client);
|
|
54
|
+
if (!target.success) return target;
|
|
55
|
+
const invalidName = Object.keys(args.headers).find((name) => !HEADER_NAME.test(name));
|
|
56
|
+
if (invalidName !== undefined) {
|
|
57
|
+
return err({ kind: "invalid_state", message: `Invalid HTTP header name: ${JSON.stringify(invalidName)}` });
|
|
58
|
+
}
|
|
59
|
+
const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: args.headers });
|
|
60
|
+
if (!result.success) return result;
|
|
61
|
+
const headerNames = Object.keys(args.headers).sort();
|
|
62
|
+
return ok({
|
|
63
|
+
text: `Set ${headerNames.length} extra HTTP header(s) on tab ${target.data.targetId}: ${headerNames.join(", ")}. Values are hidden. Clear them before switching tabs or navigating to another origin.`,
|
|
64
|
+
details: { targetId: target.data.targetId, headerNames, count: headerNames.length },
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const clearHeadersTool = defineBrowserTool({
|
|
70
|
+
name: "browser_clear_headers",
|
|
71
|
+
label: "Browser Clear Headers",
|
|
72
|
+
description: "Clear all extra HTTP request headers previously set on the current owned tab.",
|
|
73
|
+
promptSnippet: "Clear extra request headers from the current owned tab",
|
|
74
|
+
promptGuidelines: [
|
|
75
|
+
"Call after finishing an environment-specific workflow, before switching tabs, or before navigating to another origin.",
|
|
76
|
+
],
|
|
77
|
+
parameters: Type.Object({}),
|
|
78
|
+
concurrency: "serialized",
|
|
79
|
+
async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
80
|
+
const target = currentOwnedTarget(client);
|
|
81
|
+
if (!target.success) return target;
|
|
82
|
+
const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: {} });
|
|
83
|
+
if (!result.success) return result;
|
|
84
|
+
return ok({
|
|
85
|
+
text: `Cleared extra HTTP headers from tab ${target.data.targetId}.`,
|
|
86
|
+
details: { targetId: target.data.targetId },
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { BrowserClient } from "../client";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
5
|
+
import { cdpCall } from "./cdp-call";
|
|
6
|
+
|
|
7
|
+
type HistoryEntry = { readonly id: number; readonly url: string; readonly title: string };
|
|
8
|
+
type History = { readonly entries: ReadonlyArray<HistoryEntry>; readonly currentIndex: number };
|
|
9
|
+
|
|
10
|
+
const fetchHistory = async (client: BrowserClient): Promise<Result<History, ToolErr>> => {
|
|
11
|
+
const r = await cdpCall(client, "Page.getNavigationHistory", {});
|
|
12
|
+
if (!r.success) return r;
|
|
13
|
+
return ok(r.data);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const goBackTool = defineBrowserTool({
|
|
17
|
+
name: "browser_go_back",
|
|
18
|
+
label: "Browser Go Back",
|
|
19
|
+
description: "Navigate back one page in history.",
|
|
20
|
+
promptSnippet: "Go back one page",
|
|
21
|
+
promptGuidelines: ["Use to return to the previous page after navigating."],
|
|
22
|
+
parameters: Type.Object({}),
|
|
23
|
+
concurrency: "serialized",
|
|
24
|
+
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
25
|
+
const h = await fetchHistory(client);
|
|
26
|
+
if (!h.success) return h;
|
|
27
|
+
if (h.data.currentIndex <= 0) return ok({ text: "Already at the beginning of history." });
|
|
28
|
+
const target = h.data.entries[h.data.currentIndex - 1];
|
|
29
|
+
if (!target) return err({ kind: "internal", message: "history entry missing at index" });
|
|
30
|
+
const nav = await cdpCall(client, "Page.navigateToHistoryEntry", { entryId: target.id });
|
|
31
|
+
if (!nav.success) return nav;
|
|
32
|
+
return ok({ text: `Navigated back to: ${target.url}`, details: { url: target.url } });
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const goForwardTool = defineBrowserTool({
|
|
37
|
+
name: "browser_go_forward",
|
|
38
|
+
label: "Browser Go Forward",
|
|
39
|
+
description: "Navigate forward one page in history.",
|
|
40
|
+
promptSnippet: "Go forward one page",
|
|
41
|
+
promptGuidelines: ["Use to undo a browser_go_back."],
|
|
42
|
+
parameters: Type.Object({}),
|
|
43
|
+
concurrency: "serialized",
|
|
44
|
+
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
45
|
+
const h = await fetchHistory(client);
|
|
46
|
+
if (!h.success) return h;
|
|
47
|
+
if (h.data.currentIndex >= h.data.entries.length - 1) return ok({ text: "Already at the end of history." });
|
|
48
|
+
const target = h.data.entries[h.data.currentIndex + 1];
|
|
49
|
+
if (!target) return err({ kind: "internal", message: "history entry missing at index" });
|
|
50
|
+
const nav = await cdpCall(client, "Page.navigateToHistoryEntry", { entryId: target.id });
|
|
51
|
+
if (!nav.success) return nav;
|
|
52
|
+
return ok({ text: `Navigated forward to: ${target.url}`, details: { url: target.url } });
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const reloadTool = defineBrowserTool({
|
|
57
|
+
name: "browser_reload",
|
|
58
|
+
label: "Browser Reload",
|
|
59
|
+
description: "Reload the current page.",
|
|
60
|
+
promptSnippet: "Reload the page",
|
|
61
|
+
promptGuidelines: ["Use to refresh the page, e.g. after server-side changes."],
|
|
62
|
+
parameters: Type.Object({}),
|
|
63
|
+
concurrency: "serialized",
|
|
64
|
+
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
65
|
+
const r = await cdpCall(client, "Page.reload", {});
|
|
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) {
|
|
70
|
+
return ok({
|
|
71
|
+
text: `Reloaded. ⚠️ Dialog open: ${info.data.dialog.type}`,
|
|
72
|
+
details: { dialog: info.data.dialog },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return ok({
|
|
76
|
+
text: `Reloaded: ${info.data.title} (${info.data.url})`,
|
|
77
|
+
details: { page: info.data },
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { BrowserClient } from "../client";
|
|
2
|
+
import { type Result, err, ok } from "../util/result";
|
|
3
|
+
import { safeJs } from "../util/js-template";
|
|
4
|
+
import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
|
|
5
|
+
import { evaluateJson } from "../cdp/session";
|
|
6
|
+
import type { ToolErr } from "../util/tool";
|
|
7
|
+
import { cdpCallBrowser, cdpCallOnTarget, cdpErrToToolErr, evalJs } from "./cdp-call";
|
|
8
|
+
|
|
9
|
+
export type IsolatedTab = {
|
|
10
|
+
readonly targetId: string;
|
|
11
|
+
readonly sessionId: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const EVAL_TIMEOUT_MS = 15_000;
|
|
15
|
+
const READY_POLL_INTERVAL_MS = 50;
|
|
16
|
+
|
|
17
|
+
export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => {
|
|
18
|
+
// Routed through the target factory so an isolated tab lands in the pinned profile, not another profile's cookies.
|
|
19
|
+
const window = await ensureHarnessWindow(client);
|
|
20
|
+
if (!window.success) return err(cdpErrToToolErr(window.error, "Target.createTarget"));
|
|
21
|
+
let targetId: string;
|
|
22
|
+
if (window.data.freshlyCreated) {
|
|
23
|
+
targetId = window.data.targetId;
|
|
24
|
+
} else {
|
|
25
|
+
const opened = await openHarnessTab(client, window.data.targetId);
|
|
26
|
+
if (!opened.success) return err(cdpErrToToolErr(opened.error, "Target.createTarget"));
|
|
27
|
+
targetId = opened.data;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const attached = await cdpCallBrowser(client, "Target.attachToTarget", { targetId, flatten: true });
|
|
31
|
+
if (!attached.success) {
|
|
32
|
+
await client.closeTab(targetId);
|
|
33
|
+
return attached;
|
|
34
|
+
}
|
|
35
|
+
const { sessionId } = attached.data;
|
|
36
|
+
|
|
37
|
+
const enabled = await cdpCallOnTarget(client, "Page.enable", {}, sessionId);
|
|
38
|
+
if (!enabled.success) {
|
|
39
|
+
await client.closeTab(targetId);
|
|
40
|
+
return enabled;
|
|
41
|
+
}
|
|
42
|
+
return ok({ targetId, sessionId });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const navigateIsolatedTab = async (
|
|
46
|
+
client: BrowserClient,
|
|
47
|
+
tab: IsolatedTab,
|
|
48
|
+
url: string,
|
|
49
|
+
): Promise<Result<void, ToolErr>> => {
|
|
50
|
+
const nav = await cdpCallOnTarget(client, "Page.navigate", { url }, tab.sessionId, {
|
|
51
|
+
timeoutMs: EVAL_TIMEOUT_MS,
|
|
52
|
+
});
|
|
53
|
+
if (!nav.success) return nav;
|
|
54
|
+
return ok(undefined);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const waitForIsolatedLoad = async (
|
|
58
|
+
client: BrowserClient,
|
|
59
|
+
tab: IsolatedTab,
|
|
60
|
+
timeoutMs: number,
|
|
61
|
+
signal?: AbortSignal,
|
|
62
|
+
): 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` });
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const isJsonText = (v: unknown): v is string => typeof v === "string";
|
|
74
|
+
|
|
75
|
+
export const evalInIsolatedTab = async <T>(
|
|
76
|
+
client: BrowserClient,
|
|
77
|
+
tab: IsolatedTab,
|
|
78
|
+
expression: string,
|
|
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);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const closeIsolatedTab = async (client: BrowserClient, tab: IsolatedTab): Promise<void> => {
|
|
90
|
+
await client.closeTab(tab.targetId).catch(() => {});
|
|
91
|
+
};
|