pi-browser-harness 0.3.1 → 0.4.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/src/prompt.ts CHANGED
@@ -21,7 +21,7 @@ to interact with pages visually, and other tools alongside them.
21
21
 
22
22
  | Tool | Purpose |
23
23
  |------|---------|
24
- | browser_screenshot | Capture a PNG screenshot of the current page (result details include path and attached: false — the LLM reads the image via the saved file path) |
24
+ | browser_screenshot | Capture a PNG or JPEG screenshot of the current page (rendered inline in the TUI; the LLM reads the image via the saved file path) |
25
25
  | browser_click | Click at viewport coordinates |
26
26
  | browser_type | Type text into the focused element |
27
27
  | browser_press_key | Press a keyboard key (Enter, Tab, Escape, arrows, etc.) |
@@ -63,6 +63,16 @@ browser_run_script("/tmp/scrape-pages.js", { urls: [...] })
63
63
 
64
64
  Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON, Buffer, setTimeout, clearTimeout.
65
65
 
66
+ ### Parallel Execution
67
+
68
+ Observation tools (browser_screenshot, browser_page_info, browser_execute_js, browser_list_tabs, browser_http_get, etc.) can run in parallel with each other and with mutation tools. The harness automatically serializes mutation tools (click, type, scroll, navigate, switch_tab, etc.) so they never race on shared state. When operations are independent, emit them in the same turn for better performance.
69
+
70
+ **Examples of safe parallel calls:**
71
+ \`\`\`
72
+ browser_screenshot() + browser_page_info() + browser_execute_js("document.title")
73
+ browser_http_get("https://api.example.com/data") + browser_click(x, y)
74
+ \`\`\`
75
+
66
76
  ### Common Patterns
67
77
 
68
78
  **Navigation:**
@@ -85,9 +95,9 @@ browser_http_get("https://api.example.com/data")
85
95
 
86
96
  **Scrolling:**
87
97
  \`\`\`
88
- browser_screenshot() → browser_scroll({ deltaY: -500 }) → browser_screenshot()
98
+ browser_screenshot() → browser_scroll({ deltaY: 500 }) → browser_screenshot()
89
99
  \`\`\`
90
- Note: deltaY follows W3C wheel-event convention: positive=up, negative=down. Default deltaY=-300 scrolls down.
100
+ Note: deltaY follows W3C wheel-event convention: positive=down, negative=up. Default deltaY=300 scrolls down.
91
101
 
92
102
  **Research Workflow (search + browser):**
93
103
  \`\`\`
package/src/registry.ts CHANGED
@@ -9,40 +9,46 @@ import { handleDialogTool } from "./domains/dialog";
9
9
  import { screenshotTool } from "./domains/screenshot";
10
10
  import { navigateTool, openUrlsTool } from "./domains/navigate";
11
11
  import { goBackTool, goForwardTool, reloadTool } from "./domains/history";
12
- import { listTabsTool, currentTabTool, switchTabTool, newTabTool } from "./domains/tabs";
12
+ import { listTabsTool, currentTabTool, switchTabTool, newTabTool, closeTabTool } from "./domains/tabs";
13
13
  import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files";
14
14
  import { viewportResizeTool } from "./domains/viewport";
15
15
  import { dragAndDropTool } from "./domains/drag";
16
- import { httpGetTool, getNetworkLogTool } from "./domains/network";
16
+ import { httpGetTool, networkRequestsTool } from "./domains/network";
17
+ import { snapshotTool } from "./domains/snapshot";
17
18
  import { executeJsTool, runScriptTool } from "./domains/js";
18
19
 
20
+ // Mutation tools that modify page / browser state are marked `serialized: true`
21
+ // so they run through a shared async mutex. Observation / read-only tools are
22
+ // left unmarked and can execute in parallel.
19
23
  const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
20
- clickTool,
21
- typeTool,
22
- pressKeyTool,
23
- dispatchKeyTool,
24
- scrollTool,
24
+ { ...clickTool, serialized: true },
25
+ { ...typeTool, serialized: true },
26
+ { ...pressKeyTool, serialized: true },
27
+ { ...dispatchKeyTool, serialized: true },
28
+ { ...scrollTool, serialized: true },
25
29
  pageInfoTool,
26
30
  waitTool,
27
- waitForLoadTool,
28
- handleDialogTool,
31
+ { ...waitForLoadTool, serialized: true },
32
+ { ...handleDialogTool, serialized: true },
29
33
  screenshotTool,
30
- navigateTool,
31
- openUrlsTool,
32
- goBackTool,
33
- goForwardTool,
34
- reloadTool,
34
+ { ...navigateTool, serialized: true },
35
+ { ...openUrlsTool, serialized: true },
36
+ { ...goBackTool, serialized: true },
37
+ { ...goForwardTool, serialized: true },
38
+ { ...reloadTool, serialized: true },
35
39
  listTabsTool,
36
40
  currentTabTool,
37
- switchTabTool,
38
- newTabTool,
39
- uploadFileTool,
40
- downloadTool,
41
- printToPdfTool,
42
- viewportResizeTool,
43
- dragAndDropTool,
41
+ { ...switchTabTool, serialized: true },
42
+ { ...newTabTool, serialized: true },
43
+ { ...closeTabTool, serialized: true },
44
+ { ...uploadFileTool, serialized: true },
45
+ { ...downloadTool, serialized: true },
46
+ { ...printToPdfTool, serialized: true },
47
+ { ...viewportResizeTool, serialized: true },
48
+ { ...dragAndDropTool, serialized: true },
44
49
  httpGetTool,
45
- getNetworkLogTool,
50
+ networkRequestsTool,
51
+ snapshotTool,
46
52
  executeJsTool,
47
53
  runScriptTool,
48
54
  ];
package/src/setup.ts CHANGED
@@ -23,7 +23,7 @@ async function runSetup(ctx: ExtensionContext, client: BrowserClient): Promise<v
23
23
  const chromeRunning = checkChromeRunning();
24
24
  if (!chromeRunning) {
25
25
  ctx.ui.notify(
26
- "Chrome/Chromium/Edge not detected. Please start your browser and retry /browser-setup.",
26
+ "No browser instance running. Please open your browser and then run /browser-setup.",
27
27
  "error",
28
28
  );
29
29
  return;
@@ -80,11 +80,29 @@ async function runSetup(ctx: ExtensionContext, client: BrowserClient): Promise<v
80
80
 
81
81
  function checkChromeRunning(): boolean {
82
82
  try {
83
- if (process.platform === "darwin" || process.platform === "linux") {
83
+ if (process.platform === "darwin") {
84
+ // Exact line matching — excludes "Google Chrome Helper" etc.
85
+ // that linger after the user quits the browser.
84
86
  const out = execSync("ps -A -o comm=", { timeout: 5000 }).toString().toLowerCase();
85
- return ["google chrome", "chrome", "chromium", "microsoft edge", "msedge"].some(
86
- (n) => out.includes(n),
87
- );
87
+ const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
88
+ const browserProcesses = ["google chrome", "chromium", "microsoft edge"];
89
+ return lines.some((l) => {
90
+ if (l.includes("helper") || l.includes("renderer")) return false;
91
+ return browserProcesses.includes(l);
92
+ });
93
+ } else if (process.platform === "linux") {
94
+ // Use args to distinguish the main browser process from sub-processes
95
+ // (GPU, renderer, utility, etc.) which carry --type= flags.
96
+ const out = execSync("ps -A -o comm=,args=", { timeout: 5000 }).toString().toLowerCase();
97
+ const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
98
+ const browserComms = ["chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge", "google-chrome"];
99
+ return lines.some((line) => {
100
+ const parts = line.split(/\s+/);
101
+ const comm = parts[0] ?? "";
102
+ if (!comm || !browserComms.includes(comm)) return false;
103
+ const isSubprocess = line.includes("--type=") && !line.includes("--type=browser");
104
+ return !isSubprocess;
105
+ });
88
106
  } else if (process.platform === "win32") {
89
107
  const out = execSync("tasklist", { timeout: 5000 }).toString().toLowerCase();
90
108
  return ["chrome.exe", "msedge.exe"].some((n) => out.includes(n));
package/src/state.ts CHANGED
@@ -10,9 +10,14 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
10
10
  export type BrowserState = {
11
11
  readonly namespace: string;
12
12
  readonly remoteBrowserId?: string;
13
+ readonly ownedTargetIds?: ReadonlyArray<string>;
14
+ readonly harnessWindowTargetId?: string;
13
15
  };
14
16
 
15
- export const defaultState = (namespace = "default"): BrowserState => ({ namespace });
17
+ export const defaultState = (namespace = "default"): BrowserState => ({
18
+ namespace,
19
+ ownedTargetIds: [],
20
+ });
16
21
 
17
22
  export const persistState = (pi: ExtensionAPI, state: BrowserState): void => {
18
23
  pi.appendEntry<BrowserState>("browser-harness-state", state);
@@ -0,0 +1,25 @@
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
+ export type Mutex = {
7
+ acquire(): Promise<() => void>;
8
+ };
9
+
10
+ export const createMutex = (): Mutex => {
11
+ let queue = Promise.resolve<unknown>(undefined);
12
+
13
+ return {
14
+ async acquire(): Promise<() => void> {
15
+ let release = () => {};
16
+ const next = new Promise<void>((resolve) => {
17
+ release = resolve;
18
+ });
19
+ const prev = queue;
20
+ queue = prev.then(() => next);
21
+ await prev;
22
+ return release;
23
+ },
24
+ };
25
+ };
package/src/util/tool.ts CHANGED
@@ -53,6 +53,10 @@ export type BrowserToolDefinition<S extends TSchema> = {
53
53
  readonly handler: ToolHandler<S>;
54
54
  readonly renderCall?: (args: Static<S>, theme: Theme) => Component;
55
55
  readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
56
+ /** If true, the tool acquires the client's mutation mutex before executing.
57
+ * This ensures that two serialized tools never run concurrently,
58
+ * preventing race conditions on shared CDP session/page state. */
59
+ readonly serialized?: boolean;
56
60
  readonly ensureAlive?: boolean;
57
61
  };
58
62
 
@@ -146,23 +150,43 @@ export const registerBrowserTool = (
146
150
  );
147
151
  }
148
152
  }
149
- const result = await def.handler(args, {
150
- client,
151
- signal,
152
- onUpdate: (u) => {
153
- if (onUpdate) {
154
- // AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails.
155
- const update: AgentToolResult<OkDetails> = {
156
- content: [{ type: "text", text: u.text }],
157
- details: { ok: true, ...(u.details ?? {}) },
158
- };
159
- // Cast needed: onUpdate is AgentToolUpdateCallback<unknown> (generic TDetails).
160
- (onUpdate as AgentToolUpdateCallback<OkDetails>)(update);
161
- }
162
- },
163
- extensionCtx,
164
- });
165
- return toToolResult(result, def.name);
153
+ // Serialized tools acquire the client's mutation mutex so that only one
154
+ // mutation tool runs at a time. Observation tools skip this and run freely
155
+ // in parallel. AbortSignal is checked after acquiring the lock so that a
156
+ // cancelled task doesn't hold the mutex forever.
157
+ let release: (() => void) | undefined;
158
+ if (def.serialized) {
159
+ const acquire = await client.mutationMutex().acquire();
160
+ release = acquire;
161
+ if (signal?.aborted) {
162
+ release();
163
+ return toToolResult(
164
+ { success: false, error: { kind: "invalid_state", message: "Tool execution aborted before entering serialized lane" } },
165
+ def.name,
166
+ );
167
+ }
168
+ }
169
+ try {
170
+ const result = await def.handler(args, {
171
+ client,
172
+ signal,
173
+ onUpdate: (u) => {
174
+ if (onUpdate) {
175
+ // AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails.
176
+ const update: AgentToolResult<OkDetails> = {
177
+ content: [{ type: "text", text: u.text }],
178
+ details: { ok: true, ...(u.details ?? {}) },
179
+ };
180
+ // Cast needed: onUpdate is AgentToolUpdateCallback<unknown> (generic TDetails).
181
+ (onUpdate as AgentToolUpdateCallback<OkDetails>)(update);
182
+ }
183
+ },
184
+ extensionCtx,
185
+ });
186
+ return toToolResult(result, def.name);
187
+ } finally {
188
+ release?.();
189
+ }
166
190
  },
167
191
  };
168
192
  pi.registerTool(td);