pi-browser-harness 0.3.1 → 0.3.2
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 +14 -0
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +47 -2
- package/src/client.ts +6 -0
- package/src/domains/screenshot.ts +36 -0
- package/src/domains/scroll.ts +16 -9
- package/src/prompt.ts +13 -3
- package/src/registry.ts +22 -19
- package/src/setup.ts +23 -5
- package/src/util/mutex.ts +25 -0
- package/src/util/tool.ts +41 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.3.2 — 2026-05-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Parallel tool execution with automatic mutation serialization.** Observation tools (`browser_screenshot`, `browser_page_info`, `browser_execute_js`, `browser_list_tabs`, `browser_http_get` etc.) can now run in parallel with each other and with mutation tools. Mutation tools (`browser_click`, `browser_type`, `browser_scroll`, `browser_navigate`, `browser_switch_tab`, etc.) are automatically serialized through a shared async mutex so they never race on shared CDP session/page state. LLMs can emit independent operations in the same turn for better latency.
|
|
10
|
+
- **New `src/util/mutex.ts`** — lightweight async mutex (~25 LOC) with FIFO queue. `serialized?: boolean` flag added to `BrowserToolDefinition`; `mutationMutex()` exposed on `BrowserClient`.
|
|
11
|
+
- **Prompt and SKILL.md** updated with parallel-execution guidance and safe-parallel-call examples.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Scroll tool deltaY sign convention corrected.** Previously `deltaY` was documented as positive=up (inverted vs W3C wheel events). Now follows the W3C convention: positive=down, negative=up. Default changed from `-300` to `300` (scroll down). Prompt snippets, tool descriptions, and guidelines all updated.
|
|
16
|
+
- **Scroll tool now calls `Page.bringToFront`** before dispatching mouse events, preventing silent-drop when the target page is not the active browser tab. Mouse events now include explicit `button:"none"`, `buttons:0`, `pointerType:"mouse"`.
|
|
17
|
+
- **Screenshot TUI render no longer crashes the host on long file paths.** The `Image` text-fallback render did not respect terminal width, so a long path could overflow and crash the host TUI. Each rendered line is now truncated with `truncateToWidth` to fit the available width.
|
|
18
|
+
|
|
5
19
|
## 0.3.1 — 2026-05-02
|
|
6
20
|
|
|
7
21
|
### Bug fixes
|
package/package.json
CHANGED
|
@@ -32,6 +32,50 @@ browser_screenshot() → browser_click(x, y) → browser_screenshot()
|
|
|
32
32
|
Compositor-level clicks (`Input.dispatchMouseEvent`) work through iframes, shadow DOM,
|
|
33
33
|
and cross-origin content. No selectors needed.
|
|
34
34
|
|
|
35
|
+
## Parallelization
|
|
36
|
+
|
|
37
|
+
**Always parallelize when possible.** The harness automatically serializes mutation tools so they never race on shared state. Emit independent tools in the same turn for better performance.
|
|
38
|
+
|
|
39
|
+
### What can run in parallel
|
|
40
|
+
|
|
41
|
+
- **Observation tools** (`browser_screenshot`, `browser_page_info`, `browser_execute_js`, `browser_list_tabs`, `browser_current_tab`, `browser_http_get`, `browser_print_to_pdf`) can run in parallel with each other and with mutation tools.
|
|
42
|
+
|
|
43
|
+
- **Opening result pages:** Use `browser_open_urls` to open multiple URLs in parallel tabs. After opening, use `browser_list_tabs` to see the new tabs and `browser_switch_tab` to visit each one.
|
|
44
|
+
|
|
45
|
+
- **API calls:** `browser_http_get` calls are independent of browser state — fire multiple GETs in parallel.
|
|
46
|
+
|
|
47
|
+
- **Read + browser tools:** Use `read` and `browser_*` tools in the same turn — reading files or previous results is independent of browser actions.
|
|
48
|
+
|
|
49
|
+
### What must be sequential (harness enforces this)
|
|
50
|
+
|
|
51
|
+
- **Mutation tools** (`click`, `type`, `scroll`, `navigate`, `switch_tab`, `press_key`, `drag_and_drop`, `upload_file`, `go_back`, `go_forward`, `reload`, `handle_dialog`, `viewport_resize`, `wait_for_load`, `download`) are automatically queued through a mutex. Even if you emit them in parallel, they execute serially in FIFO order. You do not need to worry about race conditions.
|
|
52
|
+
|
|
53
|
+
- **Tab switching then acting:** `browser_switch_tab` must complete before acting on the switched-to tab. This is enforced automatically.
|
|
54
|
+
|
|
55
|
+
### Pattern: Parallel research across tabs
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
# Step 1: Open all result pages at once
|
|
59
|
+
browser_open_urls(["url1", "url2", "url3"])
|
|
60
|
+
|
|
61
|
+
# Step 2: Wait for them to load, then extract in parallel
|
|
62
|
+
browser_list_tabs() # get targetIds
|
|
63
|
+
|
|
64
|
+
# Step 3: Extract data from each tab (parallel)
|
|
65
|
+
browser_switch_tab(targetId1) → browser_screenshot() # These are sequential per tab
|
|
66
|
+
browser_switch_tab(targetId2) → browser_execute_js(...) # but you batch switch+act
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Pattern: Parallel observation + mutation
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
# Fire observation tools while a mutation is in progress
|
|
73
|
+
browser_click(x, y) # mutation (queued)
|
|
74
|
+
browser_screenshot() # observation (runs immediately in parallel)
|
|
75
|
+
browser_page_info() # observation (runs immediately in parallel)
|
|
76
|
+
browser_http_get("https://api.example.com") # external (runs immediately in parallel)
|
|
77
|
+
```
|
|
78
|
+
|
|
35
79
|
## Available Tools
|
|
36
80
|
|
|
37
81
|
### Navigation
|
|
@@ -49,7 +93,7 @@ and cross-origin content. No selectors needed.
|
|
|
49
93
|
- `browser_scroll` — scroll the page at coordinates
|
|
50
94
|
|
|
51
95
|
### Visual
|
|
52
|
-
- `browser_screenshot` — capture PNG screenshot
|
|
96
|
+
- `browser_screenshot` — capture PNG or JPEG screenshot (rendered inline in the TUI)
|
|
53
97
|
|
|
54
98
|
### Data Extraction
|
|
55
99
|
- `browser_execute_js` — execute JavaScript and return result
|
|
@@ -86,8 +130,9 @@ browser_http_get("https://api.example.com/data")
|
|
|
86
130
|
|
|
87
131
|
### Scrolling
|
|
88
132
|
```
|
|
89
|
-
browser_screenshot() → browser_scroll({ deltaY:
|
|
133
|
+
browser_screenshot() → browser_scroll({ deltaY: 500 }) → browser_screenshot()
|
|
90
134
|
```
|
|
135
|
+
Note: deltaY follows W3C wheel-event convention: positive=down, negative=up. Default deltaY=300 scrolls down.
|
|
91
136
|
|
|
92
137
|
### Research (search engine + browser_open_urls)
|
|
93
138
|
```
|
package/src/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Result, err, ok } from "./util/result";
|
|
2
2
|
import { safeJs } from "./util/js-template";
|
|
3
|
+
import { type Mutex, createMutex } from "./util/mutex";
|
|
3
4
|
import { discoverWsUrl } from "./cdp/discovery";
|
|
4
5
|
import { type CdpError, cdpError } from "./cdp/errors";
|
|
5
6
|
import type { CdpTransport } from "./cdp/transport";
|
|
@@ -27,6 +28,9 @@ export type BrowserClient = {
|
|
|
27
28
|
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
28
29
|
session(): CdpSession;
|
|
29
30
|
transport(): CdpTransport;
|
|
31
|
+
/** Returns the shared async mutex that serialized browser tools must acquire
|
|
32
|
+
* before performing mutations. Observation tools should not use this. */
|
|
33
|
+
mutationMutex(): Mutex;
|
|
30
34
|
};
|
|
31
35
|
|
|
32
36
|
const HEALTH_TTL_MS = 30_000;
|
|
@@ -63,6 +67,7 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
|
|
|
63
67
|
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
|
|
64
68
|
const transport = createCdpTransport();
|
|
65
69
|
const session = createCdpSession(transport);
|
|
70
|
+
const mutationMutex = createMutex();
|
|
66
71
|
let lastHealth = 0;
|
|
67
72
|
let pageCache: { readonly info: PageInfo; readonly at: number } | null = null;
|
|
68
73
|
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
|
|
@@ -228,5 +233,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
228
233
|
current: () => session.current(),
|
|
229
234
|
session: () => session,
|
|
230
235
|
transport: () => transport,
|
|
236
|
+
mutationMutex: () => mutationMutex,
|
|
231
237
|
};
|
|
232
238
|
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { writeFile, rename } from "node:fs/promises";
|
|
2
3
|
import { Type } from "typebox";
|
|
4
|
+
import { Image, type ImageTheme, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
3
5
|
import type { BrowserClient } from "../client";
|
|
4
6
|
import { type Result, err, ok } from "../util/result";
|
|
5
7
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
@@ -82,6 +84,40 @@ export const screenshotTool = defineBrowserTool({
|
|
|
82
84
|
details: { path: cap.data.path, format: cap.data.format, attached: false },
|
|
83
85
|
});
|
|
84
86
|
},
|
|
87
|
+
|
|
88
|
+
renderResult(result, _expanded, theme) {
|
|
89
|
+
const details = result.details as { path?: string; format?: string } | undefined;
|
|
90
|
+
const filePath = details?.path;
|
|
91
|
+
if (!filePath) {
|
|
92
|
+
return new Text(theme.fg("error", "Screenshot path missing"), 0, 0);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const buf = readFileSync(filePath);
|
|
96
|
+
const b64 = buf.toString("base64");
|
|
97
|
+
const mimeType = details?.format === "jpeg" ? "image/jpeg" : "image/png";
|
|
98
|
+
const imageTheme: ImageTheme = {
|
|
99
|
+
fallbackColor: (str: string) => theme.fg("dim", str),
|
|
100
|
+
};
|
|
101
|
+
const image = new Image(b64, mimeType, imageTheme, {
|
|
102
|
+
maxWidthCells: 80,
|
|
103
|
+
maxHeightCells: 24,
|
|
104
|
+
filename: filePath,
|
|
105
|
+
});
|
|
106
|
+
// Image's text fallback (when terminal lacks inline-image support) does
|
|
107
|
+
// not respect the rendered width, so a long file path can blow past the
|
|
108
|
+
// terminal width and crash the host TUI. Wrap the component to truncate
|
|
109
|
+
// each rendered line to fit.
|
|
110
|
+
return {
|
|
111
|
+
invalidate: () => image.invalidate(),
|
|
112
|
+
render: (width: number) =>
|
|
113
|
+
image.render(width).map((line) =>
|
|
114
|
+
visibleWidth(line) > width ? truncateToWidth(line, width) : line,
|
|
115
|
+
),
|
|
116
|
+
};
|
|
117
|
+
} catch {
|
|
118
|
+
return new Text(theme.fg("warning", `Screenshot saved: ${filePath}`), 0, 0);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
85
121
|
});
|
|
86
122
|
|
|
87
123
|
export const captureWithCrosshair = async (
|
package/src/domains/scroll.ts
CHANGED
|
@@ -7,8 +7,8 @@ const ScrollArgs = Type.Object({
|
|
|
7
7
|
y: Type.Optional(Type.Number({ description: "Y coordinate where to scroll. Default: viewport center" })),
|
|
8
8
|
deltaX: Type.Optional(Type.Number({ default: 0, description: "Horizontal scroll delta (CSS pixels). Positive = right." })),
|
|
9
9
|
deltaY: Type.Optional(Type.Number({
|
|
10
|
-
default:
|
|
11
|
-
description: "Vertical scroll delta (CSS pixels). Positive = up (
|
|
10
|
+
default: 300,
|
|
11
|
+
description: "Vertical scroll delta (CSS pixels). Positive = down, negative = up (W3C wheel-event convention). Default: 300 (scroll down).",
|
|
12
12
|
})),
|
|
13
13
|
});
|
|
14
14
|
|
|
@@ -16,10 +16,10 @@ export const scrollTool = defineBrowserTool({
|
|
|
16
16
|
name: "browser_scroll",
|
|
17
17
|
label: "Browser Scroll",
|
|
18
18
|
description:
|
|
19
|
-
"Scroll the page at given coordinates. deltaY follows W3C wheel-event convention: positive = scroll
|
|
20
|
-
promptSnippet: "Scroll the page (deltaY positive=
|
|
19
|
+
"Scroll the page at given coordinates. deltaY follows W3C wheel-event convention: positive = scroll down, negative = scroll up. Default scrolls down (deltaY = 300).",
|
|
20
|
+
promptSnippet: "Scroll the page (deltaY positive=down, negative=up)",
|
|
21
21
|
promptGuidelines: [
|
|
22
|
-
"Default behavior scrolls down 300px (deltaY
|
|
22
|
+
"Default behavior scrolls down 300px (deltaY=300). Pass a negative deltaY to scroll up.",
|
|
23
23
|
"Pass x/y to target a specific scrollable region (e.g., a div with overflow); otherwise scrolls the page at viewport center.",
|
|
24
24
|
],
|
|
25
25
|
parameters: ScrollArgs,
|
|
@@ -35,14 +35,21 @@ export const scrollTool = defineBrowserTool({
|
|
|
35
35
|
const cx = args.x ?? Math.round(info.data.width / 2);
|
|
36
36
|
const cy = args.y ?? Math.round(info.data.height / 2);
|
|
37
37
|
const dx = args.deltaX ?? 0;
|
|
38
|
-
const dy = args.deltaY ??
|
|
38
|
+
const dy = args.deltaY ?? 300;
|
|
39
|
+
// The page target must be focused or Input events are dropped silently
|
|
40
|
+
// (mouseWheel hangs; synthesizeScrollGesture returns ok but no scroll).
|
|
41
|
+
// Page.bringToFront makes the target the active one in the browser.
|
|
42
|
+
const front = await client.session().call("Page.bringToFront", {});
|
|
43
|
+
if (!front.success) return err({ kind: "cdp_error", message: front.error.message });
|
|
39
44
|
// Establish compositor mouse position before mouseWheel; without this,
|
|
40
|
-
// CDP sometimes drops the wheel event with a timeout.
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
// CDP sometimes drops the wheel event with a timeout.
|
|
46
|
+
const moved = await client.session().call("Input.dispatchMouseEvent", {
|
|
47
|
+
type: "mouseMoved", x: cx, y: cy, button: "none", buttons: 0,
|
|
48
|
+
});
|
|
43
49
|
if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message });
|
|
44
50
|
const wheel = await client.session().call("Input.dispatchMouseEvent", {
|
|
45
51
|
type: "mouseWheel", x: cx, y: cy, deltaX: dx, deltaY: dy,
|
|
52
|
+
button: "none", buttons: 0, pointerType: "mouse",
|
|
46
53
|
});
|
|
47
54
|
if (!wheel.success) return err({ kind: "cdp_error", message: wheel.error.message });
|
|
48
55
|
return ok({
|
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 (
|
|
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:
|
|
98
|
+
browser_screenshot() → browser_scroll({ deltaY: 500 }) → browser_screenshot()
|
|
89
99
|
\`\`\`
|
|
90
|
-
Note: deltaY follows W3C wheel-event convention: positive=
|
|
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
|
@@ -16,31 +16,34 @@ import { dragAndDropTool } from "./domains/drag";
|
|
|
16
16
|
import { httpGetTool, getNetworkLogTool } from "./domains/network";
|
|
17
17
|
import { executeJsTool, runScriptTool } from "./domains/js";
|
|
18
18
|
|
|
19
|
+
// Mutation tools that modify page / browser state are marked `serialized: true`
|
|
20
|
+
// so they run through a shared async mutex. Observation / read-only tools are
|
|
21
|
+
// left unmarked and can execute in parallel.
|
|
19
22
|
const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
|
|
20
|
-
clickTool,
|
|
21
|
-
typeTool,
|
|
22
|
-
pressKeyTool,
|
|
23
|
-
dispatchKeyTool,
|
|
24
|
-
scrollTool,
|
|
23
|
+
{ ...clickTool, serialized: true },
|
|
24
|
+
{ ...typeTool, serialized: true },
|
|
25
|
+
{ ...pressKeyTool, serialized: true },
|
|
26
|
+
{ ...dispatchKeyTool, serialized: true },
|
|
27
|
+
{ ...scrollTool, serialized: true },
|
|
25
28
|
pageInfoTool,
|
|
26
29
|
waitTool,
|
|
27
|
-
waitForLoadTool,
|
|
28
|
-
handleDialogTool,
|
|
30
|
+
{ ...waitForLoadTool, serialized: true },
|
|
31
|
+
{ ...handleDialogTool, serialized: true },
|
|
29
32
|
screenshotTool,
|
|
30
|
-
navigateTool,
|
|
31
|
-
openUrlsTool,
|
|
32
|
-
goBackTool,
|
|
33
|
-
goForwardTool,
|
|
34
|
-
reloadTool,
|
|
33
|
+
{ ...navigateTool, serialized: true },
|
|
34
|
+
{ ...openUrlsTool, serialized: true },
|
|
35
|
+
{ ...goBackTool, serialized: true },
|
|
36
|
+
{ ...goForwardTool, serialized: true },
|
|
37
|
+
{ ...reloadTool, serialized: true },
|
|
35
38
|
listTabsTool,
|
|
36
39
|
currentTabTool,
|
|
37
|
-
switchTabTool,
|
|
38
|
-
newTabTool,
|
|
39
|
-
uploadFileTool,
|
|
40
|
-
downloadTool,
|
|
41
|
-
printToPdfTool,
|
|
42
|
-
viewportResizeTool,
|
|
43
|
-
dragAndDropTool,
|
|
40
|
+
{ ...switchTabTool, serialized: true },
|
|
41
|
+
{ ...newTabTool, serialized: true },
|
|
42
|
+
{ ...uploadFileTool, serialized: true },
|
|
43
|
+
{ ...downloadTool, serialized: true },
|
|
44
|
+
{ ...printToPdfTool, serialized: true },
|
|
45
|
+
{ ...viewportResizeTool, serialized: true },
|
|
46
|
+
{ ...dragAndDropTool, serialized: true },
|
|
44
47
|
httpGetTool,
|
|
45
48
|
getNetworkLogTool,
|
|
46
49
|
executeJsTool,
|
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
|
-
"
|
|
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"
|
|
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
|
-
|
|
86
|
-
|
|
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));
|
|
@@ -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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
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);
|