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.
Files changed (90) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +102 -1
  3. package/package.json +13 -7
  4. package/skills/pi-browser-harness/SKILL.md +84 -0
  5. package/src/cdp/commands.ts +204 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +72 -37
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +115 -191
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +84 -27
  25. package/src/daemon/transport.ts +88 -52
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -7
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -66
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +64 -11
  34. package/src/domains/form-batch.ts +13 -11
  35. package/src/domains/form.ts +79 -116
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +7 -7
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +68 -53
  41. package/src/domains/keyboard.ts +16 -18
  42. package/src/domains/navigate.ts +102 -69
  43. package/src/domains/network.ts +56 -98
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +19 -58
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -54
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -14
  59. package/src/domains/scroll.ts +4 -5
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -34
  62. package/src/domains/setup.ts +0 -2
  63. package/src/domains/snapshot.ts +72 -77
  64. package/src/domains/tab-resolve.ts +36 -0
  65. package/src/domains/tabs.ts +19 -78
  66. package/src/domains/target.ts +1 -1
  67. package/src/domains/viewport.ts +0 -1
  68. package/src/index.ts +100 -39
  69. package/src/profile/bind.ts +45 -14
  70. package/src/profile/browser-process.ts +50 -12
  71. package/src/profile/command.ts +20 -12
  72. package/src/profile/launch.ts +13 -9
  73. package/src/profile/list.ts +9 -13
  74. package/src/profile/store.ts +2 -1
  75. package/src/prompt.ts +42 -117
  76. package/src/registry.ts +68 -4
  77. package/src/schemas/parse.ts +2 -2
  78. package/src/setup.ts +28 -19
  79. package/src/state.ts +33 -2
  80. package/src/util/debug.ts +7 -1
  81. package/src/util/guards.ts +3 -0
  82. package/src/util/mutex.ts +1 -1
  83. package/src/util/paths.ts +12 -1
  84. package/src/util/result.ts +8 -0
  85. package/src/util/sharp-shim.ts +18 -11
  86. package/src/util/sleep.ts +18 -0
  87. package/src/util/tool.ts +163 -114
  88. package/src/util/truncate.ts +1 -1
  89. package/tsconfig.json +2 -1
  90. package/src/domains/fill-engine.ts +0 -80
package/src/prompt.ts CHANGED
@@ -1,127 +1,52 @@
1
- /**
2
- * System prompt guidance for browser usage.
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
- export function getBrowserSystemPrompt(): string {
12
- return `
13
- ## Browser Control
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
- Each tool carries its own detailed guidelines — this table is only for routing.
8
+ export type LaneNames = {
9
+ readonly serialized: ReadonlyArray<string>;
10
+ readonly parallel: ReadonlyArray<string>;
11
+ };
54
12
 
55
- ### Workflows
13
+ const TOOL_PREFIX = "browser_";
56
14
 
57
- **Research (search → read):**
58
- \`\`\`
59
- browser_web_search("query") // ranked links, no page content
60
- → browser_read_page(url) // clean article text for the top hits
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
- **Form filling (ref-first — the reliable path on SPA forms):**
65
- \`\`\`
66
- browser_wait_for({ selector: "#email" }) // ensure the field is rendered
67
- browser_snapshot() // interactive elements get [eN] refs
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
- **Navigate + verify:**
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
- **Scroll:** \`browser_scroll({ deltaY: 500 })\` — W3C wheel convention: positive = down,
80
- negative = up (default 300, down).
81
-
82
- ### Parallelism
83
-
84
- Observation tools (\`browser_screenshot\`, \`browser_page_info\`, \`browser_execute_js\`,
85
- \`browser_list_tabs\`, \`browser_http_get\`, \`browser_web_search\`, \`browser_read_page\`, …)
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
- When built-in tools can't express a multi-step workflow, write a script to a temp file
110
- and run it with \`browser_run_script\`. It executes in the harness process with direct
111
- daemon + Node access.
112
- \`\`\`
113
- write(<tempfile>.js, \`
114
- const results = [];
115
- for (const url of params.urls) {
116
- await daemon.cdp("Page.navigate", { url });
117
- await new Promise(r => setTimeout(r, 2000));
118
- results.push({ url, title: await daemon.evaluateJS("document.title") });
119
- }
120
- return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
121
- \`)
122
- browser_run_script(<tempfile>.js, { urls: [...] })
123
- \`\`\`
124
- Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON,
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 { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
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<AnyBrowserToolDefinition> = [
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 const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => {
70
- for (const t of ALL_TOOLS) registerBrowserTool(pi, client, t);
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
  };
@@ -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<{}, S>,
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
- export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): void {
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 = { success: true; data: string } | { success: false; error: string };
29
+ export type SetupResult = Result<string, string>;
23
30
 
24
- export async function performSetup(client: BrowserClient, ctx?: ExtensionContext): Promise<SetupResult> {
25
- const chromeRunning = await isBrowserRunning();
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 { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." };
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 { success: false, error: `Could not start the browser daemon. Check ${DAEMON_SOCKET_PATH}.` };
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
- success: false,
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 { success: false, error: `Connection failed: ${msg}` };
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 { success: false, error: `Browser connected but test navigation failed: ${tabResult.error.message}` };
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" in info.data) {
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 && !("dialog" in info.data) ? info.data.url : "github.com";
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 { success: true, data: lines.join("\n") };
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
- export const persistState = (pi: ExtensionAPI, state: BrowserState): void => {
17
- pi.appendEntry<BrowserState>("browser-harness-state", state);
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 || Boolean(env["BH_DEBUG_CLICKS"]);
14
+ flagEnabled || envFlagEnabled(env["BH_DEBUG_CLICKS"]);
@@ -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
@@ -7,7 +7,7 @@ export const createMutex = (): Mutex => {
7
7
 
8
8
  return {
9
9
  async acquire(): Promise<() => void> {
10
- let release = () => {};
10
+ let release: () => void = () => undefined;
11
11
  const next = new Promise<void>((resolve) => {
12
12
  release = resolve;
13
13
  });
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
 
@@ -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);
@@ -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
- const SHARP_SPECIFIER: string = "sharp";
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 MISSING_MODULE_MARKERS: ReadonlyArray<string> = [
20
- "Cannot find module",
21
- "MODULE_NOT_FOUND",
22
- "ERR_MODULE_NOT_FOUND",
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
- const msg = e instanceof Error ? e.message : String(e);
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 m = mod as { default?: unknown };
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
+ });