pi-browser-harness 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.md +102 -1
- package/package.json +13 -7
- package/skills/pi-browser-harness/SKILL.md +84 -0
- package/src/cdp/commands.ts +204 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +72 -37
- package/src/cdp/events.ts +8 -3
- package/src/cdp/network-buffer.ts +38 -32
- package/src/cdp/ownership.ts +22 -4
- package/src/cdp/record-store.ts +2 -2
- package/src/cdp/session.ts +320 -217
- package/src/cdp/target-factory.ts +34 -15
- package/src/cdp/types.ts +17 -1
- package/src/client.ts +76 -43
- package/src/daemon/bridge.ts +115 -191
- package/src/daemon/connection.ts +323 -0
- package/src/daemon/index.ts +77 -48
- package/src/daemon/lines.ts +40 -0
- package/src/daemon/main.ts +12 -0
- package/src/daemon/protocol.ts +55 -25
- package/src/daemon/server.ts +100 -63
- package/src/daemon/socket.ts +10 -0
- package/src/daemon/spawn.ts +84 -27
- package/src/daemon/transport.ts +88 -52
- package/src/domains/ax-tree.ts +8 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -7
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -66
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +64 -11
- package/src/domains/form-batch.ts +13 -11
- package/src/domains/form.ts +79 -116
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +7 -7
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +68 -53
- package/src/domains/keyboard.ts +16 -18
- package/src/domains/navigate.ts +102 -69
- package/src/domains/network.ts +56 -98
- package/src/domains/page/fill.page.ts +139 -0
- package/src/domains/page/form.page.ts +61 -0
- package/src/domains/page/inject.ts +37 -0
- package/src/domains/page/keyboard.page.ts +16 -0
- package/src/domains/page/live-value.page.ts +10 -0
- package/src/domains/page/read-page.page.ts +51 -0
- package/src/domains/page/serp.page.ts +39 -0
- package/src/domains/page.ts +19 -58
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -54
- package/src/domains/ref-resolve.ts +43 -29
- package/src/domains/ref-signature.ts +11 -0
- package/src/domains/render.ts +12 -23
- package/src/domains/screenshot-capture.ts +4 -2
- package/src/domains/screenshot.ts +13 -14
- package/src/domains/scroll.ts +4 -5
- package/src/domains/search/google-serp.ts +1 -1
- package/src/domains/search/web-search.ts +38 -34
- package/src/domains/setup.ts +0 -2
- package/src/domains/snapshot.ts +72 -77
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +19 -78
- package/src/domains/target.ts +1 -1
- package/src/domains/viewport.ts +0 -1
- package/src/index.ts +100 -39
- package/src/profile/bind.ts +45 -14
- package/src/profile/browser-process.ts +50 -12
- package/src/profile/command.ts +20 -12
- package/src/profile/launch.ts +13 -9
- package/src/profile/list.ts +9 -13
- package/src/profile/store.ts +2 -1
- package/src/prompt.ts +42 -117
- package/src/registry.ts +68 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +33 -2
- package/src/util/debug.ts +7 -1
- package/src/util/guards.ts +3 -0
- package/src/util/mutex.ts +1 -1
- package/src/util/paths.ts +12 -1
- package/src/util/result.ts +8 -0
- package/src/util/sharp-shim.ts +18 -11
- package/src/util/sleep.ts +18 -0
- package/src/util/tool.ts +163 -114
- package/src/util/truncate.ts +1 -1
- package/tsconfig.json +2 -1
- package/src/domains/fill-engine.ts +0 -80
package/src/prompt.ts
CHANGED
|
@@ -1,127 +1,52 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Injects a compact browser control reference into the pi system prompt.
|
|
5
|
-
* This prompt carries only cross-cutting knowledge — the mental model, tool
|
|
6
|
-
* routing, workflows, and gotchas. Per-tool specifics (schemas, detailed usage)
|
|
7
|
-
* are injected separately via promptGuidelines on each tool registration, so this
|
|
8
|
-
* file intentionally does NOT restate full tool descriptions.
|
|
9
|
-
*/
|
|
1
|
+
import { ALL_TOOLS } from "./registry";
|
|
2
|
+
import type { BrowserTool, ToolConcurrency } from "./util/tool";
|
|
10
3
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
You drive a **real Chrome** (the user's running instance, not headless) through
|
|
16
|
-
\`browser_*\` tools. These coexist with every standard pi tool — read, bash, edit,
|
|
17
|
-
write — so mix them freely: \`browser_*\` for the page, everything else alongside.
|
|
18
|
-
|
|
19
|
-
### The core handle: refs
|
|
20
|
-
|
|
21
|
-
Interaction is ref-first. Call \`browser_snapshot\` to get an accessibility tree
|
|
22
|
-
where every interactive element has a stable ref (\`[eN]\`) and \`@(x,y)\`. **Target
|
|
23
|
-
elements by ref, not by coordinates or guessed selectors** — refs are keyed to
|
|
24
|
-
element identity, so they survive the re-renders that React/Vue forms trigger on
|
|
25
|
-
every edit and save. Coordinates go stale; guessed selectors miss.
|
|
26
|
-
|
|
27
|
-
- Prefer \`browser_fill\` for text — it fires input/change so controlled components
|
|
28
|
-
update, and returns the value for verification. Use \`browser_click\` + \`browser_type\`
|
|
29
|
-
only for keystroke-sensitive widgets (autocomplete, masked inputs).
|
|
30
|
-
- Mutating calls append a compact **"Page changes"** diff — read it to confirm the
|
|
31
|
-
change landed (form closed, new \`*[eN]\` appeared).
|
|
32
|
-
- \`"ref is stale"\` means the page changed: re-run \`browser_snapshot\` for fresh refs.
|
|
33
|
-
|
|
34
|
-
### Choosing a tool
|
|
35
|
-
|
|
36
|
-
| To… | Reach for |
|
|
37
|
-
|-----|-----------|
|
|
38
|
-
| See the page structure (for interaction) | \`browser_snapshot\` (refs) |
|
|
39
|
-
| See the page visually | \`browser_screenshot\` |
|
|
40
|
-
| Search the web | \`browser_web_search\` → ranked {title, url, snippet} |
|
|
41
|
-
| Read an article's clean text | \`browser_read_page\` (reader mode; url or owned tab) |
|
|
42
|
-
| Extract specific DOM values | \`browser_execute_js\` |
|
|
43
|
-
| Hit a JSON/API endpoint | \`browser_http_get\` (raw GET, outside the browser) |
|
|
44
|
-
| Click / fill / type / select / focus / press a key | \`browser_click\` · \`browser_fill\` · \`browser_type\` · \`browser_select_option\` · \`browser_focus\` · \`browser_press_key\` |
|
|
45
|
-
| Upload a file · drag · resize viewport | \`browser_upload_file\` · \`browser_drag_and_drop\` · \`browser_viewport_resize\` |
|
|
46
|
-
| Go to a URL / open many / manage tabs | \`browser_navigate\` · \`browser_new_tab\` · \`browser_open_urls\` · \`browser_list_tabs\` · \`browser_switch_tab\` · \`browser_go_back\`/\`go_forward\`/\`reload\` |
|
|
47
|
-
| Wait for content / load / a fixed delay | \`browser_wait_for\` (selector/text) · \`browser_wait_for_load\` (readyState) · \`browser_wait\` |
|
|
48
|
-
| Inspect page / handle a dialog | \`browser_page_info\` · \`browser_current_tab\` · \`browser_handle_dialog\` |
|
|
49
|
-
| Diagnose a broken action | \`browser_console\` (JS errors) · \`browser_get_network_log\` |
|
|
50
|
-
| Save output / config downloads | \`browser_print_to_pdf\` · \`browser_download\` |
|
|
51
|
-
| Anything the tools can't express | \`browser_run_script\` (see Extending) |
|
|
4
|
+
// The lane text is derived from each tool's own `concurrency`, so a tool can never
|
|
5
|
+
// be mis-described as observational while actually taking the mutation mutex.
|
|
6
|
+
export type PromptTool = Pick<BrowserTool, "name" | "concurrency">;
|
|
52
7
|
|
|
53
|
-
|
|
8
|
+
export type LaneNames = {
|
|
9
|
+
readonly serialized: ReadonlyArray<string>;
|
|
10
|
+
readonly parallel: ReadonlyArray<string>;
|
|
11
|
+
};
|
|
54
12
|
|
|
55
|
-
|
|
13
|
+
const TOOL_PREFIX = "browser_";
|
|
56
14
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// fall back to browser_open_urls + browser_execute_js only when reader mode misses
|
|
62
|
-
\`\`\`
|
|
15
|
+
// Lane lists use the short form (click, not browser_click) to keep the prompt compact; the
|
|
16
|
+
// prefix is universal and stated once in the prose above the list.
|
|
17
|
+
export const shortToolName = (name: string): string =>
|
|
18
|
+
name.startsWith(TOOL_PREFIX) ? name.slice(TOOL_PREFIX.length) : name;
|
|
63
19
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
→ browser_fill({ ref: "e7", value: "a@b.com" })
|
|
69
|
-
→ browser_select_option({ ref: "e9", label: "India" })
|
|
70
|
-
→ browser_click({ ref: "e12" }) // re-resolves position even after reflow
|
|
71
|
-
// read each "Page changes" diff to confirm the step landed
|
|
72
|
-
\`\`\`
|
|
20
|
+
export const laneNames = (tools: ReadonlyArray<PromptTool>): LaneNames => {
|
|
21
|
+
const lane = (c: ToolConcurrency): ReadonlyArray<string> => tools.filter((t) => t.concurrency === c).map((t) => t.name);
|
|
22
|
+
return { serialized: lane("serialized"), parallel: lane("parallel") };
|
|
23
|
+
};
|
|
73
24
|
|
|
74
|
-
|
|
75
|
-
\`\`\`
|
|
76
|
-
browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot()
|
|
77
|
-
\`\`\`
|
|
25
|
+
const joinShort = (names: ReadonlyArray<string>): string => names.map(shortToolName).join(", ");
|
|
78
26
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
run in parallel with each other and with mutations. The harness serializes mutations
|
|
87
|
-
(click, type, scroll, navigate, switch_tab) so they never race. Emit independent calls
|
|
88
|
-
in one turn:
|
|
89
|
-
\`\`\`
|
|
90
|
-
browser_screenshot() + browser_page_info() + browser_execute_js("document.title")
|
|
91
|
-
\`\`\`
|
|
92
|
-
|
|
93
|
-
**Multi-agent:** tab switching by one agent changes the active tab for all agents,
|
|
94
|
-
but per-tab data (console, network, dialogs) stays isolated. Call \`browser_current_tab\`
|
|
95
|
-
before a mutation to confirm you're on the expected tab. Handle any dialog reported by
|
|
96
|
-
\`browser_page_info\` promptly — dialogs block interaction and aren't queued across agents.
|
|
97
|
-
|
|
98
|
-
**Session boundary & tab hygiene:** you operate only inside this session's own Chrome
|
|
99
|
-
window. \`browser_switch_tab\`/\`browser_close_tab\` refuse tabs this session didn't open,
|
|
100
|
-
and \`browser_list_tabs\` defaults to owned tabs — never reach into the user's other tabs
|
|
101
|
-
or windows. Close tabs when you're done with them: call \`browser_close_tab\` on any tab
|
|
102
|
-
you opened (via \`browser_new_tab\`/\`browser_open_urls\`) once you've extracted what you
|
|
103
|
-
need. Don't leave a pile of stale tabs behind — keep only what a later step still needs.
|
|
104
|
-
(Leftover tabs are closed automatically when the session ends, but close them yourself
|
|
105
|
-
as you go.)
|
|
106
|
-
|
|
107
|
-
### Extending: temporary scripts
|
|
27
|
+
export function getBrowserSystemPrompt(connected = true, tools: ReadonlyArray<PromptTool> = ALL_TOOLS): string {
|
|
28
|
+
const connectionNotice = connected
|
|
29
|
+
? ""
|
|
30
|
+
: "Browser is not connected. Run browser_setup or /browser-setup to connect.\n\n";
|
|
31
|
+
const lanes = laneNames(tools);
|
|
32
|
+
return `
|
|
33
|
+
## Browser Control
|
|
108
34
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
Buffer, setTimeout, clearTimeout. Use a real temp path from the OS — don't hardcode \`/tmp\`.
|
|
35
|
+
${connectionNotice}You control the user's running Chrome through \`browser_*\` tools alongside the
|
|
36
|
+
standard pi tools. Per-tool usage lives in tool descriptions, parameter schemas,
|
|
37
|
+
and tool-specific guidelines.
|
|
38
|
+
|
|
39
|
+
- Operate only inside this session's owned Chrome window. Never reach into the
|
|
40
|
+
user's other tabs or windows. Close owned tabs once no later step needs them.
|
|
41
|
+
- Tools in the serialized lane (${joinShort(lanes.serialized)}) run one at a time.
|
|
42
|
+
Tools in the parallel lane (${joinShort(lanes.parallel)}) may run concurrently
|
|
43
|
+
with them. \`browser_run_script\` bypasses that lane: keep mutating scripts
|
|
44
|
+
sequential with other browser calls. Parallelize only independent calls.
|
|
45
|
+
- Agents sharing a session share its active tab. Check \`browser_current_tab\`
|
|
46
|
+
before a mutation to confirm the expected tab when another agent may switch it.
|
|
47
|
+
- Read any appended "Page changes" diff to confirm an interaction. Tools without a
|
|
48
|
+
diff, including \`browser_run_script\`, need explicit state verification.
|
|
49
|
+
- If a tool reports "ref is stale", call \`browser_snapshot\` for fresh refs before
|
|
50
|
+
retrying; do not reuse the stale handle.
|
|
126
51
|
`;
|
|
127
52
|
}
|
package/src/registry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import type { BrowserClient } from "./client";
|
|
3
|
-
import
|
|
3
|
+
import type { BrowserTool } from "./util/tool";
|
|
4
|
+
import { type Result, err, ok } from "./util/result";
|
|
4
5
|
import { clickTool } from "./domains/click";
|
|
5
6
|
import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
|
|
6
7
|
import { fillTool, selectOptionTool, focusTool, setCheckedTool } from "./domains/form";
|
|
@@ -16,6 +17,7 @@ import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files";
|
|
|
16
17
|
import { viewportResizeTool } from "./domains/viewport";
|
|
17
18
|
import { dragAndDropTool } from "./domains/drag";
|
|
18
19
|
import { httpGetTool, networkRequestsTool } from "./domains/network";
|
|
20
|
+
import { clearHeadersTool, setHeadersTool } from "./domains/headers";
|
|
19
21
|
import { consoleTool } from "./domains/console";
|
|
20
22
|
import { snapshotTool } from "./domains/snapshot";
|
|
21
23
|
import { executeJsTool, runScriptTool } from "./domains/js";
|
|
@@ -23,7 +25,7 @@ import { setupTool } from "./domains/setup";
|
|
|
23
25
|
import { webSearchTool } from "./domains/search/web-search";
|
|
24
26
|
import { readPageTool } from "./domains/readpage/read-page";
|
|
25
27
|
|
|
26
|
-
export const ALL_TOOLS: ReadonlyArray<
|
|
28
|
+
export const ALL_TOOLS: ReadonlyArray<BrowserTool> = [
|
|
27
29
|
setupTool,
|
|
28
30
|
clickTool,
|
|
29
31
|
typeTool,
|
|
@@ -58,6 +60,8 @@ export const ALL_TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
|
|
|
58
60
|
dragAndDropTool,
|
|
59
61
|
httpGetTool,
|
|
60
62
|
networkRequestsTool,
|
|
63
|
+
setHeadersTool,
|
|
64
|
+
clearHeadersTool,
|
|
61
65
|
consoleTool,
|
|
62
66
|
snapshotTool,
|
|
63
67
|
executeJsTool,
|
|
@@ -66,6 +70,66 @@ export const ALL_TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
|
|
|
66
70
|
readPageTool,
|
|
67
71
|
];
|
|
68
72
|
|
|
69
|
-
export
|
|
70
|
-
|
|
73
|
+
export type RegistryError = {
|
|
74
|
+
readonly kind: "duplicate_name";
|
|
75
|
+
readonly message: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// The only surface pi needs for tool registration; keeping it narrow lets tests pass a sink
|
|
79
|
+
// instead of a full ExtensionAPI.
|
|
80
|
+
export type ToolRegistrar = Pick<ExtensionAPI, "registerTool">;
|
|
81
|
+
|
|
82
|
+
export type ToolRegistry = {
|
|
83
|
+
// Current contents, in insertion order. Built-ins come first, contributed tools after.
|
|
84
|
+
readonly tools: () => ReadonlyArray<BrowserTool>;
|
|
85
|
+
// Append one tool. Rejects a name already present so a contributor cannot shadow a built-in
|
|
86
|
+
// (or another contributor) silently; the first registration wins.
|
|
87
|
+
readonly add: (tool: BrowserTool) => Result<void, RegistryError>;
|
|
88
|
+
// Append a batch atomically: a name clash anywhere in it (with the registry or within the batch)
|
|
89
|
+
// rejects the whole call and leaves the registry untouched.
|
|
90
|
+
readonly addAll: (tools: ReadonlyArray<BrowserTool>) => Result<void, RegistryError>;
|
|
91
|
+
// Register every tool that has not been registered with pi yet. Safe to call on every
|
|
92
|
+
// session_start: tools added after an earlier call land on the next one.
|
|
93
|
+
readonly register: (pi: ToolRegistrar, client: BrowserClient) => void;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const duplicateName = (name: string): RegistryError => ({
|
|
97
|
+
kind: "duplicate_name",
|
|
98
|
+
message: `A browser tool named "${name}" is already registered; pick a distinct name`,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const createToolRegistry = (initial: ReadonlyArray<BrowserTool> = ALL_TOOLS): ToolRegistry => {
|
|
102
|
+
const tools: BrowserTool[] = [];
|
|
103
|
+
const names = new Set<string>();
|
|
104
|
+
const registered = new Set<string>();
|
|
105
|
+
|
|
106
|
+
const addAll = (batch: ReadonlyArray<BrowserTool>): Result<void, RegistryError> => {
|
|
107
|
+
const seen = new Set<string>();
|
|
108
|
+
const clash = batch.find((t) => names.has(t.name) || seen.has(t.name) || (seen.add(t.name), false));
|
|
109
|
+
if (clash) return err(duplicateName(clash.name));
|
|
110
|
+
for (const tool of batch) {
|
|
111
|
+
names.add(tool.name);
|
|
112
|
+
tools.push(tool);
|
|
113
|
+
}
|
|
114
|
+
return ok(undefined);
|
|
115
|
+
};
|
|
116
|
+
const add = (tool: BrowserTool): Result<void, RegistryError> => addAll([tool]);
|
|
117
|
+
|
|
118
|
+
for (const tool of initial) {
|
|
119
|
+
const added = add(tool);
|
|
120
|
+
if (!added.success) throw new Error(added.error.message);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
tools: () => [...tools],
|
|
125
|
+
add,
|
|
126
|
+
addAll,
|
|
127
|
+
register: (pi, client) => {
|
|
128
|
+
for (const tool of tools) {
|
|
129
|
+
if (registered.has(tool.name)) continue;
|
|
130
|
+
tool.register(pi, client);
|
|
131
|
+
registered.add(tool.name);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
71
135
|
};
|
package/src/schemas/parse.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { Static, TSchema } from "typebox";
|
|
1
|
+
import type { Static, TProperties, TSchema } from "typebox";
|
|
2
2
|
import type { Validator } from "typebox/compile";
|
|
3
3
|
import { type Result, err, ok } from "../util/result";
|
|
4
4
|
|
|
5
5
|
export const parseJson = <S extends TSchema>(
|
|
6
6
|
raw: string,
|
|
7
|
-
validator: Validator<
|
|
7
|
+
validator: Validator<TProperties, S>,
|
|
8
8
|
): Result<Static<S>, string> => {
|
|
9
9
|
let parsed: unknown;
|
|
10
10
|
try {
|
package/src/setup.ts
CHANGED
|
@@ -4,12 +4,19 @@ import { ensureDaemon } from "./daemon/spawn";
|
|
|
4
4
|
import { DAEMON_SOCKET_PATH } from "./daemon/protocol";
|
|
5
5
|
import { isBrowserRunning } from "./profile/browser-process";
|
|
6
6
|
import { promptForProfileIfUnset } from "./profile/command";
|
|
7
|
+
import { type Result, err, ok } from "./util/result";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
// The machine probes setup runs before touching the client. Each defaults to the real implementation.
|
|
10
|
+
export type SetupDeps = {
|
|
11
|
+
readonly isBrowserRunning?: () => Promise<boolean>;
|
|
12
|
+
readonly ensureDaemon?: () => Promise<boolean>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient, deps: SetupDeps = {}): void {
|
|
9
16
|
pi.registerCommand("browser-setup", {
|
|
10
17
|
description: "Connect pi to your Chrome browser",
|
|
11
18
|
handler: async (_args, ctx) => {
|
|
12
|
-
const result = await performSetup(client, ctx);
|
|
19
|
+
const result = await performSetup(client, ctx, deps);
|
|
13
20
|
if (result.success) {
|
|
14
21
|
ctx.ui.notify(result.data, "info");
|
|
15
22
|
} else {
|
|
@@ -19,22 +26,26 @@ export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): v
|
|
|
19
26
|
});
|
|
20
27
|
}
|
|
21
28
|
|
|
22
|
-
export type SetupResult =
|
|
29
|
+
export type SetupResult = Result<string, string>;
|
|
23
30
|
|
|
24
|
-
export async function performSetup(
|
|
25
|
-
|
|
31
|
+
export async function performSetup(
|
|
32
|
+
client: BrowserClient,
|
|
33
|
+
ctx?: ExtensionContext,
|
|
34
|
+
deps: SetupDeps = {},
|
|
35
|
+
): Promise<SetupResult> {
|
|
36
|
+
const chromeRunning = await (deps.isBrowserRunning ?? isBrowserRunning)();
|
|
26
37
|
if (!chromeRunning) {
|
|
27
|
-
return
|
|
38
|
+
return err("No browser instance running. Please open your browser and then run /browser-setup.");
|
|
28
39
|
}
|
|
29
40
|
|
|
30
|
-
const daemonReady = await ensureDaemon();
|
|
41
|
+
const daemonReady = await (deps.ensureDaemon ?? ensureDaemon)();
|
|
31
42
|
if (!daemonReady) {
|
|
32
|
-
return
|
|
43
|
+
return err(`Could not start the browser daemon. Check ${DAEMON_SOCKET_PATH}.`);
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
const profileNote = await promptForProfileIfUnset(client, ctx);
|
|
36
47
|
|
|
37
|
-
const startResult = await client.start();
|
|
48
|
+
const startResult = await client.start({ reconnectNow: true });
|
|
38
49
|
if (!startResult.success) {
|
|
39
50
|
const msg = startResult.error.message;
|
|
40
51
|
const lower = msg.toLowerCase();
|
|
@@ -45,35 +56,33 @@ export async function performSetup(client: BrowserClient, ctx?: ExtensionContext
|
|
|
45
56
|
lower.includes("econnrefused") ||
|
|
46
57
|
lower.includes("cannot reach chrome devtools")
|
|
47
58
|
) {
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
error:
|
|
51
|
-
"Browser remote debugging needs to be enabled.\n\n" +
|
|
59
|
+
return err(
|
|
60
|
+
"Browser remote debugging needs to be enabled.\n\n" +
|
|
52
61
|
"Open chrome://inspect/#remote-debugging (or brave://inspect,\n" +
|
|
53
62
|
"edge://inspect) in your browser, tick the\n" +
|
|
54
63
|
'"Discover network targets" / Allow checkbox, then retry.\n\n' +
|
|
55
64
|
"If that doesn't expose DevTools, relaunch the browser with\n" +
|
|
56
65
|
"--remote-debugging-port=9222.\n\n" +
|
|
57
66
|
"Or set BU_CDP_WS to a remote browser WebSocket URL.",
|
|
58
|
-
|
|
67
|
+
);
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
return
|
|
70
|
+
return err(`Connection failed: ${msg}`);
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
const tabResult = await client.newTab("https://github.com");
|
|
65
74
|
if (!tabResult.success) {
|
|
66
|
-
return
|
|
75
|
+
return err(`Browser connected but test navigation failed: ${tabResult.error.message}`);
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
const info = await client.pageInfo();
|
|
70
|
-
if (info.success && "dialog"
|
|
79
|
+
if (info.success && info.data.kind === "dialog") {
|
|
71
80
|
await client.session().call("Page.handleJavaScriptDialog", { accept: true });
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
const pageUrl = info.success &&
|
|
83
|
+
const pageUrl = info.success && info.data.kind === "page" ? info.data.url : "github.com";
|
|
75
84
|
const lines = ["Browser connected ✓", `Navigated to: ${pageUrl}`];
|
|
76
85
|
if (profileNote) lines.push(profileNote);
|
|
77
|
-
return
|
|
86
|
+
return ok(lines.join("\n"));
|
|
78
87
|
}
|
|
79
88
|
|
package/src/state.ts
CHANGED
|
@@ -13,8 +13,39 @@ export const defaultState = (namespace = "default"): BrowserState => ({
|
|
|
13
13
|
ownedTargetIds: [],
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// Pi session JSONL is append-only and every custom entry shows up in the branch navigator, so a
|
|
17
|
+
// snapshot is only worth appending when it differs from the one the branch already holds (#26).
|
|
18
|
+
// Tab order is not a difference: ownership is a set.
|
|
19
|
+
const stateKey = (state: BrowserState): string =>
|
|
20
|
+
JSON.stringify({
|
|
21
|
+
namespace: state.namespace,
|
|
22
|
+
ownedTargetIds: [...(state.ownedTargetIds ?? [])].sort(),
|
|
23
|
+
harnessWindowTargetId: state.harnessWindowTargetId ?? null,
|
|
24
|
+
harnessWindowId: state.harnessWindowId ?? null,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type StatePersister = {
|
|
28
|
+
// Appends the snapshot unless it equals the last one persisted or restored. Returns whether it wrote.
|
|
29
|
+
readonly persist: (state: BrowserState) => boolean;
|
|
30
|
+
// Records a snapshot that already lives in the session (restored from the branch) without writing it.
|
|
31
|
+
readonly markPersisted: (state: BrowserState) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Only appendEntry is needed, so a test can hand in a plain object instead of a whole ExtensionAPI.
|
|
35
|
+
export const createStatePersister = (pi: Pick<ExtensionAPI, "appendEntry">): StatePersister => {
|
|
36
|
+
let last: string | undefined;
|
|
37
|
+
return {
|
|
38
|
+
persist: (state) => {
|
|
39
|
+
const key = stateKey(state);
|
|
40
|
+
if (key === last) return false;
|
|
41
|
+
pi.appendEntry<BrowserState>("browser-harness-state", state);
|
|
42
|
+
last = key;
|
|
43
|
+
return true;
|
|
44
|
+
},
|
|
45
|
+
markPersisted: (state) => {
|
|
46
|
+
last = stateKey(state);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
18
49
|
};
|
|
19
50
|
|
|
20
51
|
const asPersistedState = (v: unknown): Partial<BrowserState> | undefined => {
|
package/src/util/debug.ts
CHANGED
|
@@ -4,5 +4,11 @@ export const setDebugClicks = (on: boolean): void => {
|
|
|
4
4
|
flagEnabled = on;
|
|
5
5
|
};
|
|
6
6
|
|
|
7
|
+
const TRUTHY: ReadonlySet<string> = new Set(["1", "true"]);
|
|
8
|
+
|
|
9
|
+
// Only an explicit "1" or "true" counts: "0" and "false" are set to turn the overlay off, not on.
|
|
10
|
+
const envFlagEnabled = (value: string | undefined): boolean =>
|
|
11
|
+
value !== undefined && TRUTHY.has(value.trim().toLowerCase());
|
|
12
|
+
|
|
7
13
|
export const debugClicksEnabled = (env: NodeJS.ProcessEnv = process.env): boolean =>
|
|
8
|
-
flagEnabled ||
|
|
14
|
+
flagEnabled || envFlagEnabled(env["BH_DEBUG_CLICKS"]);
|
package/src/util/guards.ts
CHANGED
|
@@ -6,6 +6,9 @@ export const asRecord = (v: unknown): Readonly<Record<string, unknown>> | undefi
|
|
|
6
6
|
|
|
7
7
|
export const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
8
8
|
|
|
9
|
+
export const asNonEmptyString = (v: unknown): string | undefined =>
|
|
10
|
+
typeof v === "string" && v.length > 0 ? v : undefined;
|
|
11
|
+
|
|
9
12
|
export const asNumber = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
|
|
10
13
|
|
|
11
14
|
export const asBoolean = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined);
|
package/src/util/mutex.ts
CHANGED
package/src/util/paths.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
// `relative` rather than a string prefix: it is separator-agnostic and carries each platform's
|
|
6
|
+
// own case rules, so a Windows path is not rejected for its backslashes or its drive-letter case.
|
|
7
|
+
export const isPathWithin = (root: string, target: string): boolean => {
|
|
8
|
+
const absRoot = resolve(root);
|
|
9
|
+
const absTarget = resolve(target);
|
|
10
|
+
if (absTarget === absRoot) return true;
|
|
11
|
+
const rel = relative(absRoot, absTarget);
|
|
12
|
+
// A bare ".." or a ".."-led segment escapes; a sibling merely named "..foo" does not.
|
|
13
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
14
|
+
};
|
|
4
15
|
|
|
5
16
|
const NAMESPACE_RE = /^[a-zA-Z0-9_-]+$/;
|
|
6
17
|
|
package/src/util/result.ts
CHANGED
|
@@ -7,3 +7,11 @@ export const err = <E>(error: E): Result<never, E> => ({ success: false, error }
|
|
|
7
7
|
|
|
8
8
|
export const map = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> =>
|
|
9
9
|
r.success ? ok(f(r.data)) : r;
|
|
10
|
+
|
|
11
|
+
export const andThen = <T, U, E, F>(r: Result<T, E>, f: (t: T) => Result<U, F>): Result<U, E | F> =>
|
|
12
|
+
r.success ? f(r.data) : r;
|
|
13
|
+
|
|
14
|
+
export const mapErr = <T, E, F>(r: Result<T, E>, f: (e: E) => F): Result<T, F> =>
|
|
15
|
+
r.success ? r : err(f(r.error));
|
|
16
|
+
|
|
17
|
+
export const unwrapOr = <T, E>(r: Result<T, E>, fallback: T): T => (r.success ? r.data : fallback);
|
package/src/util/sharp-shim.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// `sharp` is an optional dependency not installed in every workspace, so the import must stay dynamic and unresolvable to tsc.
|
|
2
2
|
|
|
3
|
+
import { errnoCode, isRecord } from "./guards";
|
|
4
|
+
|
|
3
5
|
export type SharpInstance = {
|
|
4
6
|
metadata(): Promise<{ width?: number; height?: number }>;
|
|
5
7
|
composite(layers: ReadonlyArray<{ input: Buffer; top: number; left: number }>): SharpInstance;
|
|
@@ -14,29 +16,34 @@ export type SharpLoad =
|
|
|
14
16
|
| { readonly kind: "missing" }
|
|
15
17
|
| { readonly kind: "error"; readonly message: string };
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
// Kept as a variable so the bundler does not try to resolve the optional dependency statically.
|
|
20
|
+
const SHARP_SPECIFIER = "sharp";
|
|
21
|
+
|
|
22
|
+
// Node reports an absent package through the error code: ERR_MODULE_NOT_FOUND from the ESM loader
|
|
23
|
+
// ("Cannot find package 'sharp' imported from …"), MODULE_NOT_FOUND from require. Matching the message
|
|
24
|
+
// text misses the ESM form, so an uninstalled sharp used to be reported as a load failure.
|
|
25
|
+
const MISSING_MODULE_CODES: ReadonlySet<string> = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]);
|
|
18
26
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
27
|
+
export const classifySharpImportFailure = (e: unknown): SharpLoad => {
|
|
28
|
+
const code = errnoCode(e);
|
|
29
|
+
if (code !== undefined && MISSING_MODULE_CODES.has(code)) return { kind: "missing" };
|
|
30
|
+
return { kind: "error", message: e instanceof Error ? e.message : String(e) };
|
|
31
|
+
};
|
|
24
32
|
|
|
25
33
|
export const loadSharp = async (): Promise<SharpLoad> => {
|
|
26
34
|
let mod: unknown;
|
|
27
35
|
try {
|
|
28
36
|
mod = await import(SHARP_SPECIFIER);
|
|
29
37
|
} catch (e) {
|
|
30
|
-
|
|
31
|
-
if (MISSING_MODULE_MARKERS.some((marker) => msg.includes(marker))) return { kind: "missing" };
|
|
32
|
-
return { kind: "error", message: msg };
|
|
38
|
+
return classifySharpImportFailure(e);
|
|
33
39
|
}
|
|
34
40
|
if (mod === null || mod === undefined) return { kind: "missing" };
|
|
35
41
|
// The CommonJS sharp module's default export IS the factory; ESM-wrapped sharp puts it on .default.
|
|
36
|
-
const
|
|
37
|
-
const candidate: unknown = typeof m.default === "function" ? m.default : mod;
|
|
42
|
+
const candidate: unknown = isRecord(mod) && typeof mod["default"] === "function" ? mod["default"] : mod;
|
|
38
43
|
if (typeof candidate !== "function") {
|
|
39
44
|
return { kind: "error", message: "sharp module did not expose a callable factory" };
|
|
40
45
|
}
|
|
46
|
+
// The only thing verifiable at runtime is that sharp exported a function; its signature is taken on trust.
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- optional-dependency boundary, see scripts/boundary-allowlist.ts
|
|
41
48
|
return { kind: "ok", sharp: candidate as SharpFactory };
|
|
42
49
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Resolves (never rejects) after `ms`, or as soon as `signal` aborts. Shared by every poll loop so
|
|
2
|
+
// there is one spelling of "cancellable wait" across the cdp, profile and domain layers.
|
|
3
|
+
export const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
4
|
+
new Promise((resolve) => {
|
|
5
|
+
if (signal?.aborted) {
|
|
6
|
+
resolve();
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const onAbort = (): void => {
|
|
10
|
+
clearTimeout(timer);
|
|
11
|
+
resolve();
|
|
12
|
+
};
|
|
13
|
+
const timer = setTimeout(() => {
|
|
14
|
+
signal?.removeEventListener("abort", onAbort);
|
|
15
|
+
resolve();
|
|
16
|
+
}, ms);
|
|
17
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
18
|
+
});
|