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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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: -500 }) → browser_screenshot()
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 (
@@ -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: -300,
11
- description: "Vertical scroll delta (CSS pixels). Positive = up (matches W3C wheel events). Default: -300 (scroll down).",
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 up, negative = scroll down. Default scrolls down (deltaY = -300).",
20
- promptSnippet: "Scroll the page (deltaY positive=up, negative=down)",
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=-300). Pass a positive deltaY to scroll up.",
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 ?? -300;
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. Fix preserved
41
- // from v0.2.0.
42
- const moved = await client.session().call("Input.dispatchMouseEvent", { type: "mouseMoved", x: cx, y: cy });
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 (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
@@ -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
- "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));
@@ -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);