pi-browser-harness 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +86 -0
  3. package/package.json +12 -6
  4. package/skills/pi-browser-harness/SKILL.md +57 -2
  5. package/src/cdp/commands.ts +200 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +110 -349
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +21 -22
  25. package/src/daemon/transport.ts +80 -49
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -2
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -64
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +13 -9
  34. package/src/domains/form-batch.ts +13 -10
  35. package/src/domains/form.ts +78 -107
  36. package/src/domains/headers.ts +2 -2
  37. package/src/domains/history.ts +4 -4
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +65 -44
  41. package/src/domains/keyboard.ts +14 -11
  42. package/src/domains/navigate.ts +102 -66
  43. package/src/domains/network.ts +55 -95
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +18 -51
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -53
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -10
  59. package/src/domains/scroll.ts +4 -4
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -33
  62. package/src/domains/snapshot.ts +71 -73
  63. package/src/domains/tab-resolve.ts +36 -0
  64. package/src/domains/tabs.ts +18 -69
  65. package/src/domains/target.ts +1 -1
  66. package/src/index.ts +90 -33
  67. package/src/profile/bind.ts +45 -14
  68. package/src/profile/browser-process.ts +50 -12
  69. package/src/profile/command.ts +20 -12
  70. package/src/profile/launch.ts +13 -9
  71. package/src/profile/list.ts +9 -13
  72. package/src/profile/store.ts +2 -1
  73. package/src/prompt.ts +32 -7
  74. package/src/registry.ts +65 -4
  75. package/src/schemas/parse.ts +2 -2
  76. package/src/setup.ts +28 -19
  77. package/src/state.ts +2 -1
  78. package/src/util/debug.ts +7 -1
  79. package/src/util/guards.ts +3 -0
  80. package/src/util/mutex.ts +1 -1
  81. package/src/util/paths.ts +3 -2
  82. package/src/util/result.ts +8 -0
  83. package/src/util/sharp-shim.ts +18 -11
  84. package/src/util/sleep.ts +18 -0
  85. package/src/util/tool.ts +163 -114
  86. package/src/util/truncate.ts +1 -1
  87. package/tsconfig.json +2 -1
  88. package/src/domains/fill-engine.ts +0 -80
package/src/domains/js.ts CHANGED
@@ -4,28 +4,53 @@ import { isPathWithin } from "../util/paths";
4
4
  import { tmpdir } from "node:os";
5
5
  import { createRequire } from "node:module";
6
6
  import { Type } from "typebox";
7
- import { Markdown, Text } from "@mariozechner/pi-tui";
7
+ import { Markdown } from "@mariozechner/pi-tui";
8
8
  import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
9
9
  import { type Result, err, ok } from "../util/result";
10
10
  import { formatBytes } from "../util/truncate";
11
- import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
11
+ import { type DetailsOf, defineBrowserTool, isJsonObject, type ToolErr, type ToolOk } from "../util/tool";
12
12
  import { evalJs } from "./cdp-call";
13
13
  import { applyTruncation } from "../util/truncate";
14
- import { asNumber, asRecord, asString, isRecord } from "../util/guards";
14
+ import { isRecord } from "../util/guards";
15
15
 
16
16
  const COMPACT_PREVIEW_BYTES = 120;
17
17
 
18
- // The AsyncFunction constructor is the documented way to compile user source; a plain `unknown` cast would lose the constructor signature, so this typed cast stays.
19
- const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as
20
- new (...args: ReadonlyArray<string>) => (...args: ReadonlyArray<unknown>) => Promise<unknown>;
18
+ type AsyncFunctionConstructor = new (...args: ReadonlyArray<string>) => (...args: ReadonlyArray<unknown>) => Promise<unknown>;
19
+
20
+ // The AsyncFunction constructor is the documented way to compile user source. It is only reachable through
21
+ // Object.getPrototypeOf, which returns `any`; this is the one sanctioned cast (budgeted in boundary-allowlist.ts)
22
+ // because a plain `unknown` would lose the constructor signature and nothing can narrow back to it.
23
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-type-assertion -- the constructor is only reachable through an `any`-typed prototype lookup */
24
+ const AsyncFunction = Object.getPrototypeOf(async () => { /* only its prototype is wanted */ }).constructor as
25
+ AsyncFunctionConstructor;
26
+ /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-type-assertion */
21
27
 
22
28
  const requireFromHere = createRequire(import.meta.url);
23
29
 
30
+ // Pretty-prints for the expanded renderer; a truncated (unparseable) payload is shown as-is.
31
+ const prettyJson = (text: string): string => {
32
+ try {
33
+ const parsed: unknown = JSON.parse(text);
34
+ return JSON.stringify(parsed, null, 2);
35
+ } catch {
36
+ return text;
37
+ }
38
+ };
39
+
24
40
  const ExecuteJsArgs = Type.Object({
25
41
  expression: Type.String({ description: "JavaScript expression. `return X` is auto-wrapped in an IIFE." }),
26
42
  targetId: Type.Optional(Type.String({ description: "Optional iframe targetId; default = current page." })),
27
43
  });
28
44
 
45
+ const ExecuteJsDetailsSchema = Type.Object({
46
+ valueLength: Type.Number(),
47
+ preview: Type.String(),
48
+ isJson: Type.Boolean(),
49
+ fullOutputPath: Type.Optional(Type.String()),
50
+ });
51
+
52
+ export type ExecuteJsDetails = DetailsOf<typeof ExecuteJsDetailsSchema>;
53
+
29
54
  export const executeJsTool = defineBrowserTool({
30
55
  name: "browser_execute_js",
31
56
  label: "Browser Execute JS",
@@ -40,46 +65,35 @@ export const executeJsTool = defineBrowserTool({
40
65
  "Result must be JSON-serializable (Runtime.evaluate returnByValue=true).",
41
66
  ],
42
67
  parameters: ExecuteJsArgs,
68
+ details: ExecuteJsDetailsSchema,
43
69
  concurrency: "parallel",
44
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
70
+ async handler(args, { client }): Promise<Result<ToolOk<ExecuteJsDetails>, ToolErr>> {
45
71
  const r = await evalJs(client, args.expression, args.targetId);
46
72
  if (!r.success) return r;
47
73
  const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data);
74
+ // The full value lives in the text (and in fullOutputPath when truncated); details carry only what the
75
+ // renderer needs so a large result is not persisted twice.
76
+ const isJson = r.data !== undefined;
48
77
  const truncated = await applyTruncation(valueStr, "js");
49
- let pretty: string | undefined;
50
- try {
51
- const parsed: unknown = JSON.parse(valueStr);
52
- pretty = JSON.stringify(parsed, null, 2);
53
- } catch {
54
- pretty = undefined;
55
- }
78
+ const preview = valueStr.length > COMPACT_PREVIEW_BYTES ? valueStr.slice(0, COMPACT_PREVIEW_BYTES) + "…" : valueStr;
56
79
  return ok({
57
80
  text: truncated.text,
58
81
  details: {
59
82
  valueLength: valueStr.length,
60
- full: valueStr.length > 200_000 ? valueStr.slice(0, 200_000) : valueStr,
61
- ...(pretty !== undefined ? { pretty: pretty.length > 200_000 ? pretty.slice(0, 200_000) : pretty } : {}),
83
+ preview,
84
+ isJson,
62
85
  ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
63
86
  },
64
87
  });
65
88
  },
66
89
 
67
- renderResult(result, expanded, theme) {
68
- const raw = asRecord(result.details);
69
- if (raw === undefined) return new Text(theme.fg("error", "execute_js: no details"), 0, 0);
70
- const details = {
71
- valueLength: asNumber(raw["valueLength"]),
72
- full: asString(raw["full"]),
73
- pretty: asString(raw["pretty"]),
74
- fullOutputPath: asString(raw["fullOutputPath"]),
75
- };
76
-
77
- const len = details.valueLength ?? 0;
78
- const full = details.full ?? "";
79
- const isJson = details.pretty !== undefined;
90
+ renderResult(result, expanded) {
91
+ const { valueLength: len, preview, isJson, fullOutputPath } = result.details;
92
+ const content = result.content[0];
93
+ const fullText = content?.type === "text" ? content.text : preview;
94
+ const body = isJson ? prettyJson(fullText) : fullText;
80
95
 
81
96
  if (!expanded) {
82
- const preview = full.length > COMPACT_PREVIEW_BYTES ? full.slice(0, COMPACT_PREVIEW_BYTES) + "…" : full;
83
97
  const md = [
84
98
  `**${formatBytes(len)}** ${isJson ? "JSON" : "value"}`,
85
99
  "```",
@@ -90,10 +104,9 @@ export const executeJsTool = defineBrowserTool({
90
104
  return new Markdown(md, 0, 0, getMarkdownTheme());
91
105
  }
92
106
 
93
- const body = details.pretty ?? full;
94
107
  const fence = isJson ? "```json" : "```";
95
- const tail = details.fullOutputPath
96
- ? `\n\nFull value at \`${details.fullOutputPath}\` · ${keyHint("app.tools.expand", "to collapse")}`
108
+ const tail = fullOutputPath !== undefined
109
+ ? `\n\nFull value at \`${fullOutputPath}\` · ${keyHint("app.tools.expand", "to collapse")}`
97
110
  : `\n\n${keyHint("app.tools.expand", "to collapse")}`;
98
111
  const md = [`**${formatBytes(len)}** ${isJson ? "JSON" : "value"}`, fence, body, "```", tail].join("\n");
99
112
  return new Markdown(md, 0, 0, getMarkdownTheme());
@@ -144,11 +157,11 @@ export const runScriptTool = defineBrowserTool({
144
157
  concurrency: "parallel",
145
158
  async handler(args, { client, signal, onUpdate, extensionCtx }): Promise<Result<ToolOk, ToolErr>> {
146
159
  if (!isAbsolute(args.path)) {
147
- return err({ kind: "invalid_state", message: "Script path must be absolute" });
160
+ return err({ kind: "invalid_args", message: "Script path must be absolute" });
148
161
  }
149
162
  if (!isPathAllowed(args.path)) {
150
163
  return err({
151
- kind: "invalid_state",
164
+ kind: "invalid_args",
152
165
  message: `Script path outside allowed directories (allowed: ${allowedRoots().join(", ")})`,
153
166
  });
154
167
  }
@@ -161,9 +174,9 @@ export const runScriptTool = defineBrowserTool({
161
174
  message: `Failed to read script: ${e instanceof Error ? e.message : String(e)}`,
162
175
  });
163
176
  }
164
- if (source.length === 0) return err({ kind: "invalid_state", message: "Script is empty" });
177
+ if (source.length === 0) return err({ kind: "invalid_args", message: "Script is empty" });
165
178
  if (source.length > MAX_SOURCE_BYTES) {
166
- return err({ kind: "invalid_state", message: `Script exceeds ${MAX_SOURCE_BYTES}B size cap` });
179
+ return err({ kind: "invalid_args", message: `Script exceeds ${MAX_SOURCE_BYTES}B size cap` });
167
180
  }
168
181
 
169
182
  let executeFn: (...a: ReadonlyArray<unknown>) => Promise<unknown>;
@@ -175,16 +188,16 @@ export const runScriptTool = defineBrowserTool({
175
188
  );
176
189
  } catch (e) {
177
190
  return err({
178
- kind: "invalid_state",
191
+ kind: "invalid_args",
179
192
  message: `Syntax error: ${e instanceof Error ? e.message : String(e)}`,
180
193
  });
181
194
  }
182
195
 
183
196
  const timeoutMs = args.timeoutMs ?? 60_000;
184
197
  const ac = new AbortController();
185
- const onAbort = (): void => ac.abort();
198
+ const onAbort = (): void => { ac.abort(); };
186
199
  if (signal !== undefined) signal.addEventListener("abort", onAbort, { once: true });
187
- const timeoutTimer = setTimeout(() => ac.abort(), timeoutMs);
200
+ const timeoutTimer = setTimeout(onAbort, timeoutMs);
188
201
 
189
202
  try {
190
203
  const scriptPromise = executeFn(
@@ -200,13 +213,17 @@ export const runScriptTool = defineBrowserTool({
200
213
  if (!isRecord(first)) return;
201
214
  const txt = first["text"];
202
215
  if (typeof txt !== "string") return;
203
- try { onUpdate({ text: txt }); } catch {}
216
+ try {
217
+ onUpdate({ text: txt });
218
+ } catch {
219
+ // Progress is best-effort; a renderer failure must not abort the script.
220
+ }
204
221
  },
205
- extensionCtx ?? { cwd: process.cwd() },
222
+ extensionCtx,
206
223
  console, fetch, JSON, Buffer, setTimeout, clearTimeout,
207
224
  );
208
225
  const abortPromise = new Promise<never>((_, reject) => {
209
- ac.signal.addEventListener("abort", () => reject(new Error("script aborted (timeout or cancellation)")), { once: true });
226
+ ac.signal.addEventListener("abort", () => { reject(new Error("script aborted (timeout or cancellation)")); }, { once: true });
210
227
  });
211
228
  const result = await Promise.race([scriptPromise, abortPromise]);
212
229
  clearTimeout(timeoutTimer);
@@ -224,7 +241,11 @@ export const runScriptTool = defineBrowserTool({
224
241
  }
225
242
  const textOut = content.map((c) => c.text).join("\n");
226
243
  const details = result["details"];
227
- return isRecord(details) ? ok({ text: textOut, details }) : ok({ text: textOut });
244
+ if (details === undefined) return ok({ text: textOut });
245
+ if (!isJsonObject(details)) {
246
+ return err({ kind: "invalid_state", message: "Script details must be a plain JSON object (no Map, function or undefined values)" });
247
+ }
248
+ return ok({ text: textOut, details });
228
249
  } catch (e) {
229
250
  clearTimeout(timeoutTimer);
230
251
  if (signal !== undefined) signal.removeEventListener("abort", onAbort);
@@ -1,14 +1,22 @@
1
1
  import { Type } from "typebox";
2
+ import type { ParamsOf } from "../cdp/commands";
2
3
  import { safeJs } from "../util/js-template";
3
4
  import { type Result, err, ok } from "../util/result";
4
5
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
6
  import { cdpCall, evalJs } from "./cdp-call";
6
- import { resolveRefToObjectId } from "./ref-resolve";
7
+ import { runOnElement } from "./element-call";
8
+ import { elementFunctionSource } from "./page/inject";
9
+ import { dispatchKeyEvent, type KeyEventSpec } from "./page/keyboard.page";
10
+
11
+ const DISPATCH_KEY_FN = elementFunctionSource(dispatchKeyEvent);
7
12
 
8
13
  const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
9
14
  Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32,
10
15
  ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
11
16
  Home: 36, End: 35, PageUp: 33, PageDown: 34,
17
+ Insert: 45, Shift: 16, Control: 17, Alt: 18, Meta: 91, CapsLock: 20, ContextMenu: 93,
18
+ F1: 112, F2: 113, F3: 114, F4: 115, F5: 116, F6: 117,
19
+ F7: 118, F8: 119, F9: 120, F10: 121, F11: 122, F12: 123,
12
20
  };
13
21
 
14
22
  const virtualKeyCode = (key: string): number =>
@@ -92,7 +100,7 @@ export const pressKeyTool = defineBrowserTool({
92
100
  const code = virtualKeyCode(k);
93
101
  const modifiers = args.modifiers ?? 0;
94
102
  const isChar = k.length === 1;
95
- const downParams: Record<string, unknown> = {
103
+ const downParams: ParamsOf<"Input.dispatchKeyEvent"> = {
96
104
  type: "keyDown",
97
105
  key: k,
98
106
  code: k,
@@ -147,14 +155,9 @@ export const dispatchKeyTool = defineBrowserTool({
147
155
  // Legacy handlers branch on e.keyCode/e.which rather than e.key, so populate both.
148
156
  const code = virtualKeyCode(args.key);
149
157
  if (args.ref !== undefined) {
150
- const objectId = await resolveRefToObjectId(client, args.ref);
151
- if (!objectId.success) return objectId;
152
- const r = await cdpCall(client, "Runtime.callFunctionOn", {
153
- objectId: objectId.data,
154
- functionDeclaration: `function (type, key, keyCode) { this.dispatchEvent(new KeyboardEvent(type, { key, keyCode, which: keyCode, bubbles: true, cancelable: true })); return 1; }`,
155
- arguments: [{ value: eventType }, { value: args.key }, { value: code }],
156
- returnByValue: true,
157
- });
158
+ // Through runOnElement so the resolved RemoteObject is released after the call.
159
+ const spec: KeyEventSpec = { type: eventType, key: args.key, code };
160
+ const r = await runOnElement(client, { ref: args.ref, functionDeclaration: DISPATCH_KEY_FN, args: [spec] });
158
161
  if (!r.success) return r;
159
162
  return ok({
160
163
  text: `Dispatched ${eventType}('${args.key}') on ${target}`,
@@ -162,7 +165,7 @@ export const dispatchKeyTool = defineBrowserTool({
162
165
  });
163
166
  }
164
167
  if (args.selector === undefined) {
165
- return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
168
+ return err({ kind: "invalid_args", message: "Provide either `ref` or `selector`." });
166
169
  }
167
170
  const expr = safeJs`
168
171
  (() => {
@@ -1,11 +1,13 @@
1
1
  import { Type } from "typebox";
2
2
  import { Text } from "@mariozechner/pi-tui";
3
+ import type { BrowserClient } from "../client";
3
4
  import { type Result, err, ok } from "../util/result";
4
5
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
6
  import { applyTruncation } from "../util/truncate";
6
7
  import { safeJs } from "../util/js-template";
7
- import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
8
- import { cdpCall, cdpCallBrowser, cdpCallOnTarget, evalJs } from "./cdp-call";
8
+ import { borrowSession, ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
9
+ import { cdpCall, cdpCallBrowser, cdpCallOnTarget, evalJs, fromClient } from "./cdp-call";
10
+ import { pollUntil, READY_STATE_COMPLETE } from "./poll";
9
11
 
10
12
  const NavigateArgs = Type.Object({
11
13
  url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
@@ -29,12 +31,12 @@ export const navigateTool = defineBrowserTool({
29
31
  concurrency: "serialized",
30
32
  renderCall: (a) => new Text(`🌐 Navigate to ${a.url}`, 0, 0),
31
33
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
32
- const tabs = await client.listTabs(false);
33
- if (!tabs.success) return err({ kind: "cdp_error", message: tabs.error.message });
34
+ const tabs = fromClient(await client.listTabs(false), "Target.getTargets");
35
+ if (!tabs.success) return tabs;
34
36
  let outcome: NavOutcome;
35
37
  if (tabs.data.length === 0) {
36
- const created = await client.newTab(args.url);
37
- if (!created.success) return err({ kind: "cdp_error", message: created.error.message });
38
+ const created = fromClient(await client.newTab(args.url), "Target.createTarget");
39
+ if (!created.success) return created;
38
40
  outcome = { kind: "new_tab_created", targetId: created.data, reason: "no_tabs" };
39
41
  } else {
40
42
  const nav = await cdpCall(client, "Page.navigate", { url: args.url });
@@ -43,9 +45,9 @@ export const navigateTool = defineBrowserTool({
43
45
  if (!cur) return err({ kind: "internal", message: "navigate succeeded but no current target tracked" });
44
46
  outcome = { kind: "in_place", targetId: cur.targetId };
45
47
  }
46
- const info = await client.pageInfo();
47
- if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
48
- if ("dialog" in info.data) {
48
+ const info = fromClient(await client.pageInfo(), "pageInfo");
49
+ if (!info.success) return info;
50
+ if (info.data.kind === "dialog") {
49
51
  return ok({
50
52
  text: `Navigated to ${args.url}\nTarget: ${outcome.targetId}\n⚠️ Dialog open: ${info.data.dialog.type} — "${info.data.dialog.message}"\nUse browser_handle_dialog.`,
51
53
  details: { outcome, dialog: info.data.dialog },
@@ -68,6 +70,76 @@ type TabResult = {
68
70
  readonly targetId: string;
69
71
  readonly ok: boolean;
70
72
  readonly error?: string;
73
+ readonly note?: string;
74
+ };
75
+
76
+ // The marker is applied once the document has loaded; applying it right after Page.navigate would mark the
77
+ // outgoing document and be overwritten by the incoming one.
78
+ const TITLE_MARK_TIMEOUT_MS = 10_000;
79
+
80
+ // Opens one tab per url in a single harness window. All tabs must land in ONE window: a parallel newWindow:true
81
+ // per url would scatter them across N windows, so the first url seeds the window and the rest open as its children.
82
+ const createTabs = async (client: BrowserClient, urls: ReadonlyArray<string>): Promise<Result<ReadonlyArray<TabResult>, ToolErr>> => {
83
+ const url0 = urls[0];
84
+ if (url0 === undefined) return err({ kind: "invalid_args", message: "no URLs provided" });
85
+ const window = await ensureHarnessWindow(client);
86
+ if (!window.success) return fromClient(window, "Target.createTarget");
87
+ const seededWindow = window.data.targetId;
88
+ const seedResult: TabResult | undefined = window.data.freshlyCreated
89
+ ? { url: url0, targetId: seededWindow, ok: true }
90
+ : undefined;
91
+ const created = await Promise.all(urls.map(async (url, i): Promise<TabResult> => {
92
+ if (i === 0 && seedResult) return seedResult;
93
+ const r = await openHarnessTab(client, seededWindow);
94
+ if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
95
+ return { url, targetId: r.data, ok: true };
96
+ }));
97
+ return ok(created);
98
+ };
99
+
100
+ // Cosmetic, and polled per tab in parallel: a slow interval keeps N tabs from hammering the daemon socket.
101
+ const TITLE_MARK_POLL_MS = 250;
102
+
103
+ const markTitle = async (client: BrowserClient, sessionId: string, signal: AbortSignal | undefined): Promise<Result<void, ToolErr>> => {
104
+ const loaded = await pollUntil(client, READY_STATE_COMPLETE, { timeoutMs: TITLE_MARK_TIMEOUT_MS, intervalMs: TITLE_MARK_POLL_MS, signal, sessionId, what: "page load" });
105
+ if (!loaded.success) return loaded;
106
+ const marked = await evalJs(client, safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`, sessionId);
107
+ if (!marked.success) return marked;
108
+ return ok(undefined);
109
+ };
110
+
111
+ // Borrows a session on a created tab, navigates it, and marks its title after load. A marker failure is a note, not a failure.
112
+ // The borrowed session is released in every path so an untracked attach never leaks past this call.
113
+ const navigateTab = async (client: BrowserClient, tab: TabResult, signal: AbortSignal | undefined): Promise<TabResult> => {
114
+ const borrowed = fromClient(await borrowSession(client, tab.targetId), "Target.attachToTarget");
115
+ if (!borrowed.success) return { ...tab, ok: false, error: borrowed.error.message };
116
+ const sid = borrowed.data.sessionId;
117
+ try {
118
+ const enabled = await cdpCallOnTarget(client, "Page.enable", {}, sid);
119
+ if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message };
120
+ const nav = await cdpCallOnTarget(client, "Page.navigate", { url: tab.url }, sid);
121
+ if (!nav.success) return { ...tab, ok: false, error: nav.error.message };
122
+ const marked = await markTitle(client, sid, signal);
123
+ return marked.success ? tab : { ...tab, note: "still loading" };
124
+ } finally {
125
+ await borrowed.data.release();
126
+ }
127
+ };
128
+
129
+ const renderOutcomes = (all: ReadonlyArray<TabResult>, total: number): string => {
130
+ const okTabs = all.filter((r) => r.ok);
131
+ const failTabs = all.filter((r) => !r.ok);
132
+ const lines: string[] = [];
133
+ if (okTabs.length) {
134
+ lines.push(
135
+ `✅ ${okTabs.length}/${total} tabs opened:`,
136
+ ...okTabs.map((t, i) => ` [${i}] ${t.url} → ${t.targetId}${t.note !== undefined ? ` (${t.note})` : ""}`),
137
+ );
138
+ }
139
+ if (failTabs.length) {
140
+ lines.push(`❌ ${failTabs.length}/${total} failed:`, ...failTabs.map((t) => ` ${t.url}: ${t.error ?? "unknown"}`));
141
+ }
142
+ return lines.join("\n");
71
143
  };
72
144
 
73
145
  export const openUrlsTool = defineBrowserTool({
@@ -82,77 +154,41 @@ export const openUrlsTool = defineBrowserTool({
82
154
  parameters: OpenUrlsArgs,
83
155
  concurrency: "serialized",
84
156
  renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
85
- async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
157
+ async handler(args, { client, onUpdate, signal }): Promise<Result<ToolOk, ToolErr>> {
86
158
  const total = args.urls.length;
87
- // All tabs must land in ONE window: a parallel newWindow:true per url would scatter them across N windows, so the first url creates the window and the rest open as its children.
88
- const url0 = args.urls[0];
89
- if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" });
90
- const window = await ensureHarnessWindow(client);
91
- if (!window.success) return err({ kind: "cdp_error", message: window.error.message });
92
- const seededWindow = window.data.targetId;
93
- const seedResult: TabResult | undefined = window.data.freshlyCreated
94
- ? { url: url0, targetId: seededWindow, ok: true }
95
- : undefined;
96
- const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => {
97
- if (i === 0 && seedResult) return seedResult;
98
- const r = await openHarnessTab(client, seededWindow);
99
- if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
100
- return { url, targetId: r.data, ok: true };
101
- }));
159
+ const created = await createTabs(client, args.urls);
160
+ if (!created.success) return created;
161
+ const toNavigate = created.data.filter((t) => t.ok);
102
162
  let completed = 0;
103
163
  const settled = await Promise.all(
104
- created.filter((t) => t.ok).map(async (tab): Promise<TabResult> => {
164
+ toNavigate.map(async (tab): Promise<TabResult> => {
105
165
  try {
106
- const attached = await client.session().attach(tab.targetId);
107
- if (!attached.success) return { ...tab, ok: false, error: attached.error.message };
108
- const sid = attached.data;
109
- const enabled = await cdpCallOnTarget(client, "Page.enable", {}, sid);
110
- if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message };
111
- const nav = await cdpCallOnTarget(client, "Page.navigate", { url: tab.url }, sid);
112
- if (!nav.success) return { ...tab, ok: false, error: nav.error.message };
113
- evalJs(
114
- client,
115
- safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
116
- sid
117
- ).catch(() => {});
118
- return tab;
166
+ return await navigateTab(client, tab, signal);
119
167
  } finally {
120
168
  completed++;
121
169
  // onUpdate must never escape: a renderer crash would otherwise tear down the tool call.
122
170
  try {
123
- onUpdate({ text: `Opening URLs… ${completed}/${created.filter((t) => t.ok).length} navigated` });
124
- } catch {}
171
+ onUpdate({ text: `Opening URLs… ${completed}/${toNavigate.length} navigated` });
172
+ } catch {
173
+ // Progress is best-effort; the navigation outcome is what the tool reports.
174
+ }
125
175
  }
126
176
  }),
127
177
  );
128
- const failures = created.filter((t) => !t.ok);
129
- const all: ReadonlyArray<TabResult> = [...settled, ...failures];
178
+ const all: ReadonlyArray<TabResult> = [...settled, ...created.data.filter((t) => !t.ok)];
130
179
  const okTabs = all.filter((r) => r.ok);
131
- const failTabs = all.filter((r) => !r.ok);
132
- if (okTabs.length > 0) {
133
- const last = okTabs[okTabs.length - 1];
134
- if (last) {
135
- await cdpCallBrowser(client, "Target.activateTarget", { targetId: last.targetId });
136
- }
137
- }
138
- const lines: string[] = [];
139
- if (okTabs.length) {
140
- lines.push(`✅ ${okTabs.length}/${total} tabs opened:`, ...okTabs.map((t, i) => ` [${i}] ${t.url} → ${t.targetId}`));
180
+ const last = okTabs[okTabs.length - 1];
181
+ if (last !== undefined) {
182
+ await cdpCallBrowser(client, "Target.activateTarget", { targetId: last.targetId });
141
183
  }
142
- if (failTabs.length) {
143
- lines.push(`❌ ${failTabs.length}/${total} failed:`, ...failTabs.map((t) => ` ${t.url}: ${t.error ?? "unknown"}`));
144
- }
145
- const truncated = await applyTruncation(lines.join("\n"), "urls");
184
+ const truncated = await applyTruncation(renderOutcomes(all, total), "urls");
185
+ const details = {
186
+ tabs: all,
187
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
188
+ };
146
189
  if (okTabs.length === 0) {
147
- return err({
148
- kind: "cdp_error",
149
- message: `All ${total} URLs failed`,
150
- ...(truncated.fullOutputPath ? { details: { tabs: all, fullOutputPath: truncated.fullOutputPath } } : { details: { tabs: all } }),
151
- });
190
+ return err({ kind: "cdp_error", message: `All ${total} URLs failed`, details });
152
191
  }
153
- return ok({
154
- text: truncated.text,
155
- ...(truncated.fullOutputPath ? { details: { tabs: all, fullOutputPath: truncated.fullOutputPath } } : { details: { tabs: all } }),
156
- });
192
+ return ok({ text: truncated.text, details });
157
193
  },
158
194
  });