pi-browser-harness 0.10.2 → 0.12.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 (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +375 -265
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -37
  28. package/src/domains/console.ts +48 -4
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
package/src/prompt.ts CHANGED
@@ -1,127 +1,27 @@
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
- */
10
-
11
- export function getBrowserSystemPrompt(): string {
1
+ export function getBrowserSystemPrompt(connected = true): string {
2
+ const connectionNotice = connected
3
+ ? ""
4
+ : "Browser is not connected. Run browser_setup or /browser-setup to connect.\n\n";
12
5
  return `
13
6
  ## Browser Control
14
7
 
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) |
52
-
53
- Each tool carries its own detailed guidelines — this table is only for routing.
54
-
55
- ### Workflows
56
-
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
- \`\`\`
63
-
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
- \`\`\`
73
-
74
- **Navigate + verify:**
75
- \`\`\`
76
- browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot()
77
- \`\`\`
78
-
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
108
-
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\`.
8
+ ${connectionNotice}You control the user's running Chrome through \`browser_*\` tools alongside the
9
+ standard pi tools. Per-tool usage lives in tool descriptions, parameter schemas,
10
+ and tool-specific guidelines.
11
+
12
+ - Operate only inside this session's owned Chrome window. Never reach into the
13
+ user's other tabs or windows. Close owned tabs once no later step needs them.
14
+ - Mutating tools (click, type, fill, press_key, scroll, navigate, history, tab
15
+ switch/new/close, upload, download, headers, viewport, drag, web_search) are
16
+ serialized. Observations (snapshot, screenshot, page_info, current_tab, list_tabs,
17
+ console, network_requests, http_get, read_page, execute_js, wait, wait_for) may run
18
+ concurrently with them. \`browser_run_script\` bypasses that lane: keep mutating
19
+ scripts sequential with other browser calls. Parallelize only independent calls.
20
+ - Agents sharing a session share its active tab. Check \`browser_current_tab\`
21
+ before a mutation to confirm the expected tab when another agent may switch it.
22
+ - Read any appended "Page changes" diff to confirm an interaction. Tools without a
23
+ diff, including \`browser_run_script\`, need explicit state verification.
24
+ - If a tool reports "ref is stale", call \`browser_snapshot\` for fresh refs before
25
+ retrying; do not reuse the stale handle.
126
26
  `;
127
27
  }
package/src/registry.ts CHANGED
@@ -3,7 +3,8 @@ import type { BrowserClient } from "./client";
3
3
  import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
4
4
  import { clickTool } from "./domains/click";
5
5
  import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
6
- import { fillTool, selectOptionTool, focusTool } from "./domains/form";
6
+ import { fillTool, selectOptionTool, focusTool, setCheckedTool } from "./domains/form";
7
+ import { fillFormTool } from "./domains/form-batch";
7
8
  import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
8
9
  import { scrollTool } from "./domains/scroll";
9
10
  import { handleDialogTool } from "./domains/dialog";
@@ -15,6 +16,7 @@ import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files";
15
16
  import { viewportResizeTool } from "./domains/viewport";
16
17
  import { dragAndDropTool } from "./domains/drag";
17
18
  import { httpGetTool, networkRequestsTool } from "./domains/network";
19
+ import { clearHeadersTool, setHeadersTool } from "./domains/headers";
18
20
  import { consoleTool } from "./domains/console";
19
21
  import { snapshotTool } from "./domains/snapshot";
20
22
  import { executeJsTool, runScriptTool } from "./domains/js";
@@ -22,53 +24,51 @@ import { setupTool } from "./domains/setup";
22
24
  import { webSearchTool } from "./domains/search/web-search";
23
25
  import { readPageTool } from "./domains/readpage/read-page";
24
26
 
25
- // Mutation tools that modify page / browser state are marked `serialized: true`
26
- // so they run through a shared async mutex. Observation / read-only tools are
27
- // left unmarked and can execute in parallel.
28
- const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
29
- // setup comes first — the agent calls this to self-recover from not-initialized
27
+ export const ALL_TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
30
28
  setupTool,
31
- { ...clickTool, serialized: true },
32
- { ...typeTool, serialized: true },
33
- { ...fillTool, serialized: true },
34
- { ...selectOptionTool, serialized: true },
35
- { ...focusTool, serialized: true },
36
- { ...pressKeyTool, serialized: true },
37
- { ...dispatchKeyTool, serialized: true },
38
- { ...scrollTool, serialized: true },
29
+ clickTool,
30
+ typeTool,
31
+ fillTool,
32
+ fillFormTool,
33
+ selectOptionTool,
34
+ setCheckedTool,
35
+ focusTool,
36
+ pressKeyTool,
37
+ dispatchKeyTool,
38
+ scrollTool,
39
39
  pageInfoTool,
40
40
  waitTool,
41
41
  waitForTool,
42
- { ...waitForLoadTool, serialized: true },
43
- { ...handleDialogTool, serialized: true },
42
+ waitForLoadTool,
43
+ handleDialogTool,
44
44
  screenshotTool,
45
- { ...navigateTool, serialized: true },
46
- { ...openUrlsTool, serialized: true },
47
- { ...goBackTool, serialized: true },
48
- { ...goForwardTool, serialized: true },
49
- { ...reloadTool, serialized: true },
45
+ navigateTool,
46
+ openUrlsTool,
47
+ goBackTool,
48
+ goForwardTool,
49
+ reloadTool,
50
50
  listTabsTool,
51
51
  currentTabTool,
52
- { ...switchTabTool, serialized: true },
53
- { ...newTabTool, serialized: true },
54
- { ...closeTabTool, serialized: true },
55
- { ...uploadFileTool, serialized: true },
56
- { ...downloadTool, serialized: true },
57
- { ...printToPdfTool, serialized: true },
58
- { ...viewportResizeTool, serialized: true },
59
- { ...dragAndDropTool, serialized: true },
52
+ switchTabTool,
53
+ newTabTool,
54
+ closeTabTool,
55
+ uploadFileTool,
56
+ downloadTool,
57
+ printToPdfTool,
58
+ viewportResizeTool,
59
+ dragAndDropTool,
60
60
  httpGetTool,
61
61
  networkRequestsTool,
62
+ setHeadersTool,
63
+ clearHeadersTool,
62
64
  consoleTool,
63
65
  snapshotTool,
64
66
  executeJsTool,
65
67
  runScriptTool,
66
- // Opens and owns an isolated tab to scrape a SERP — same class as openUrlsTool.
67
- { ...webSearchTool, serialized: true },
68
- // Pure isolated read (own sessionId, own tab) — safe concurrent, like executeJsTool.
68
+ webSearchTool,
69
69
  readPageTool,
70
70
  ];
71
71
 
72
72
  export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => {
73
- for (const t of TOOLS) registerBrowserTool(pi, client, t);
73
+ for (const t of ALL_TOOLS) registerBrowserTool(pi, client, t);
74
74
  };
@@ -0,0 +1,23 @@
1
+ import type { Static, TSchema } from "typebox";
2
+ import type { Validator } from "typebox/compile";
3
+ import { type Result, err, ok } from "../util/result";
4
+
5
+ export const parseJson = <S extends TSchema>(
6
+ raw: string,
7
+ validator: Validator<{}, S>,
8
+ ): Result<Static<S>, string> => {
9
+ let parsed: unknown;
10
+ try {
11
+ parsed = JSON.parse(raw);
12
+ } catch (e) {
13
+ return err(`JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
14
+ }
15
+ if (!validator.Check(parsed)) {
16
+ const messages = validator
17
+ .Errors(parsed)
18
+ .map((x) => x.message)
19
+ .join("; ");
20
+ return err(messages.length > 0 ? messages : "JSON did not match the expected shape");
21
+ }
22
+ return ok(parsed);
23
+ };
package/src/setup.ts CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * Setup — verifies Chrome is running with remote debugging and connects
3
- * the pi agent to it. Exposed as both a slash command (/browser-setup) for
4
- * users and a tool (browser_setup) for the agent to self-recover.
5
- */
6
-
7
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
8
2
  import type { BrowserClient } from "./client";
9
3
  import { ensureDaemon } from "./daemon/spawn";
@@ -11,8 +5,6 @@ import { DAEMON_SOCKET_PATH } from "./daemon/protocol";
11
5
  import { isBrowserRunning } from "./profile/browser-process";
12
6
  import { promptForProfileIfUnset } from "./profile/command";
13
7
 
14
- // ── Public: register the /browser-setup slash command ──────────────────────
15
-
16
8
  export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): void {
17
9
  pi.registerCommand("browser-setup", {
18
10
  description: "Connect pi to your Chrome browser",
@@ -27,32 +19,21 @@ export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): v
27
19
  });
28
20
  }
29
21
 
30
- // ── Shared setup result ────────────────────────────────────────────────────
31
-
32
22
  export type SetupResult = { success: true; data: string } | { success: false; error: string };
33
23
 
34
- // ── Shared setup logic (used by both the command and the tool) ─────────────
35
-
36
24
  export async function performSetup(client: BrowserClient, ctx?: ExtensionContext): Promise<SetupResult> {
37
- // Step 1: Check Chrome is running
38
25
  const chromeRunning = await isBrowserRunning();
39
26
  if (!chromeRunning) {
40
27
  return { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." };
41
28
  }
42
29
 
43
- // Step 2: Start the browser daemon (spawns if not running, silently reuses if alive)
44
30
  const daemonReady = await ensureDaemon();
45
31
  if (!daemonReady) {
46
32
  return { success: false, error: `Could not start the browser daemon. Check ${DAEMON_SOCKET_PATH}.` };
47
33
  }
48
34
 
49
- // Step 3: Ask which profile to work in, the first time only. Enumeration
50
- // reads DevToolsActivePort + Local State from disk, so it works before the
51
- // connection exists. Declining leaves the pre-profile behavior untouched —
52
- // this must never block setup.
53
35
  const profileNote = await promptForProfileIfUnset(client, ctx);
54
36
 
55
- // Step 4: Connect to Chrome DevTools
56
37
  const startResult = await client.start();
57
38
  if (!startResult.success) {
58
39
  const msg = startResult.error.message;
@@ -80,7 +61,6 @@ export async function performSetup(client: BrowserClient, ctx?: ExtensionContext
80
61
  return { success: false, error: `Connection failed: ${msg}` };
81
62
  }
82
63
 
83
- // Step 5: Verify with test navigation
84
64
  const tabResult = await client.newTab("https://github.com");
85
65
  if (!tabResult.success) {
86
66
  return { success: false, error: `Browser connected but test navigation failed: ${tabResult.error.message}` };
@@ -97,8 +77,3 @@ export async function performSetup(client: BrowserClient, ctx?: ExtensionContext
97
77
  return { success: true, data: lines.join("\n") };
98
78
  }
99
79
 
100
- // ── Chrome process detection ───────────────────────────────────────────────
101
- //
102
- // Detection moved to src/profile/browser-process.ts, which answers the same
103
- // "is a browser running?" question per platform and additionally reports the
104
- // executable path and any explicit --user-data-dir that profile selection needs.
package/src/state.ts CHANGED
@@ -1,15 +1,8 @@
1
- /**
2
- * Session persistence for pi-browser-harness.
3
- *
4
- * Persists the daemon namespace and (when applicable) the remote browser ID
5
- * across session reloads and branch navigation.
6
- */
7
-
8
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { asArrayOf, asNumber, asString, isRecord } from "./util/guards";
9
3
 
10
4
  export type BrowserState = {
11
5
  readonly namespace: string;
12
- readonly remoteBrowserId?: string;
13
6
  readonly ownedTargetIds?: ReadonlyArray<string>;
14
7
  readonly harnessWindowTargetId?: string;
15
8
  readonly harnessWindowId?: number;
@@ -20,24 +13,61 @@ export const defaultState = (namespace = "default"): BrowserState => ({
20
13
  ownedTargetIds: [],
21
14
  });
22
15
 
23
- export const persistState = (pi: ExtensionAPI, state: BrowserState): void => {
24
- 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
+ export const createStatePersister = (pi: ExtensionAPI): StatePersister => {
35
+ let last: string | undefined;
36
+ return {
37
+ persist: (state) => {
38
+ const key = stateKey(state);
39
+ if (key === last) return false;
40
+ pi.appendEntry<BrowserState>("browser-harness-state", state);
41
+ last = key;
42
+ return true;
43
+ },
44
+ markPersisted: (state) => {
45
+ last = stateKey(state);
46
+ },
47
+ };
48
+ };
49
+
50
+ const asPersistedState = (v: unknown): Partial<BrowserState> | undefined => {
51
+ if (!isRecord(v)) return undefined;
52
+ const namespace = asString(v["namespace"]);
53
+ const ownedTargetIds = asArrayOf(v["ownedTargetIds"], asString);
54
+ const harnessWindowTargetId = asString(v["harnessWindowTargetId"]);
55
+ const harnessWindowId = asNumber(v["harnessWindowId"]);
56
+ return {
57
+ ...(namespace !== undefined ? { namespace } : {}),
58
+ ...(ownedTargetIds !== undefined ? { ownedTargetIds } : {}),
59
+ ...(harnessWindowTargetId !== undefined ? { harnessWindowTargetId } : {}),
60
+ ...(harnessWindowId !== undefined ? { harnessWindowId } : {}),
61
+ };
25
62
  };
26
63
 
27
- /**
28
- * Find the last browser-harness-state entry in the current branch
29
- * and return the restored state, merged with defaults.
30
- *
31
- * If `currentNamespace` is supplied (e.g. from the --browser-namespace flag),
32
- * it overrides whatever is in the persisted entry.
33
- */
34
64
  export const restoreState = (ctx: ExtensionContext, currentNamespace?: string): BrowserState => {
35
65
  const branchEntries = ctx.sessionManager.getBranch();
36
66
  const fallback = defaultState(currentNamespace);
37
67
  for (let i = branchEntries.length - 1; i >= 0; i--) {
38
68
  const entry = branchEntries[i];
39
69
  if (entry?.type === "custom" && entry.customType === "browser-harness-state") {
40
- const data = entry.data as Partial<BrowserState> | undefined;
70
+ const data = asPersistedState(entry.data);
41
71
  if (data) {
42
72
  return {
43
73
  ...fallback,
@@ -0,0 +1,8 @@
1
+ let flagEnabled = false;
2
+
3
+ export const setDebugClicks = (on: boolean): void => {
4
+ flagEnabled = on;
5
+ };
6
+
7
+ export const debugClicksEnabled = (env: NodeJS.ProcessEnv = process.env): boolean =>
8
+ flagEnabled || Boolean(env["BH_DEBUG_CLICKS"]);
@@ -0,0 +1,30 @@
1
+ export const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
2
+ typeof v === "object" && v !== null && !Array.isArray(v);
3
+
4
+ export const asRecord = (v: unknown): Readonly<Record<string, unknown>> | undefined =>
5
+ isRecord(v) ? v : undefined;
6
+
7
+ export const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
8
+
9
+ export const asNumber = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
10
+
11
+ export const asBoolean = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined);
12
+
13
+ export const asArrayOf = <T>(
14
+ v: unknown,
15
+ item: (x: unknown) => T | undefined,
16
+ ): ReadonlyArray<T> | undefined => {
17
+ if (!Array.isArray(v)) return undefined;
18
+ const out: T[] = [];
19
+ for (const x of v) {
20
+ const parsed = item(x);
21
+ if (parsed === undefined) return undefined;
22
+ out.push(parsed);
23
+ }
24
+ return out;
25
+ };
26
+
27
+ export const errnoCode = (e: unknown): string | undefined => {
28
+ if (!(e instanceof Error) || !("code" in e)) return undefined;
29
+ return asString(e.code);
30
+ };
@@ -1,19 +1,4 @@
1
- /**
2
- * Build a JavaScript source string with safely-interpolated values.
3
- * Every interpolated value is JSON.stringify'd, so strings, numbers,
4
- * objects, and special characters all become valid JS literals.
5
- *
6
- * This is the ONLY supported way to interpolate untrusted values into
7
- * evaluation source — never use raw template literals for JS that
8
- * crosses the CDP boundary. The static parts of the template (the
9
- * `strings` array) must remain hard-coded; only the `${...}` slots
10
- * are sanitized.
11
- *
12
- * Values must be JSON-serializable: BigInt and Symbol throw at runtime;
13
- * functions and undefined serialize to `undefined`/omitted, which yields
14
- * a ReferenceError when evaluated. Pass strings, numbers, booleans,
15
- * plain objects, or arrays.
16
- */
1
+ // The ONLY supported way to interpolate untrusted values into evaluation source — never use raw template literals for JS crossing the CDP boundary.
17
2
  export const safeJs = (
18
3
  strings: TemplateStringsArray,
19
4
  ...values: ReadonlyArray<unknown>
package/src/util/mutex.ts CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Lightweight async mutex (~10 lines). Acquire returns a release function.
3
- * When the mutex is free, acquire() resolves immediately. When held,
4
- * callers are queued and execute serially in FIFO order.
5
- */
6
1
  export type Mutex = {
7
2
  acquire(): Promise<() => void>;
8
3
  };
package/src/util/paths.ts CHANGED
@@ -1,6 +1,16 @@
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 } 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
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
13
+ };
4
14
 
5
15
  const NAMESPACE_RE = /^[a-zA-Z0-9_-]+$/;
6
16
 
@@ -7,9 +7,3 @@ 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>(r: Result<T, E>, f: (t: T) => Result<U, E>): Result<U, E> =>
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));
@@ -1,10 +1,4 @@
1
- // Typed minimal shim for the optional `sharp` dependency. Sharp is a heavy
2
- // native module not installed in every workspace; we import dynamically and
3
- // distinguish "missing" (no install) from "error" (installed but threw).
4
- //
5
- // We declare only the surface we use. The dynamic import returns `unknown`
6
- // at the boundary; the cast inside loadSharp() is the documented escape
7
- // hatch — every other consumer sees the typed SharpFactory.
1
+ // `sharp` is an optional dependency not installed in every workspace, so the import must stay dynamic and unresolvable to tsc.
8
2
 
9
3
  export type SharpInstance = {
10
4
  metadata(): Promise<{ width?: number; height?: number }>;
@@ -20,26 +14,25 @@ export type SharpLoad =
20
14
  | { readonly kind: "missing" }
21
15
  | { readonly kind: "error"; readonly message: string };
22
16
 
17
+ const SHARP_SPECIFIER: string = "sharp";
18
+
19
+ const MISSING_MODULE_MARKERS: ReadonlyArray<string> = [
20
+ "Cannot find module",
21
+ "MODULE_NOT_FOUND",
22
+ "ERR_MODULE_NOT_FOUND",
23
+ ];
24
+
23
25
  export const loadSharp = async (): Promise<SharpLoad> => {
24
26
  let mod: unknown;
25
27
  try {
26
- // sharp is an optional dependency; it may not be installed.
27
- // @ts-ignore — optional peer dependency not required at compile time
28
- mod = await import("sharp");
28
+ mod = await import(SHARP_SPECIFIER);
29
29
  } catch (e) {
30
30
  const msg = e instanceof Error ? e.message : String(e);
31
- if (
32
- msg.includes("Cannot find module") ||
33
- msg.includes("MODULE_NOT_FOUND") ||
34
- msg.includes("ERR_MODULE_NOT_FOUND")
35
- ) {
36
- return { kind: "missing" };
37
- }
31
+ if (MISSING_MODULE_MARKERS.some((marker) => msg.includes(marker))) return { kind: "missing" };
38
32
  return { kind: "error", message: msg };
39
33
  }
40
34
  if (mod === null || mod === undefined) return { kind: "missing" };
41
- // The CommonJS sharp module's default export IS the factory. ESM-wrapped
42
- // sharp puts it on .default. Probe both.
35
+ // The CommonJS sharp module's default export IS the factory; ESM-wrapped sharp puts it on .default.
43
36
  const m = mod as { default?: unknown };
44
37
  const candidate: unknown = typeof m.default === "function" ? m.default : mod;
45
38
  if (typeof candidate !== "function") {