pi-browser-harness 0.10.2 → 0.11.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 +28 -0
  2. package/README.md +5 -9
  3. package/package.json +5 -2
  4. package/skills/pi-browser-harness/SKILL.md +1 -1
  5. package/src/cdp/commands.ts +256 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +28 -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 +150 -203
  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 +18 -68
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
  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 -32
  28. package/src/domains/console.ts +48 -2
  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 +22 -26
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +65 -0
  35. package/src/domains/form.ts +116 -105
  36. package/src/domains/history.ts +13 -9
  37. package/src/domains/isolated-tab.ts +26 -55
  38. package/src/domains/js.ts +29 -42
  39. package/src/domains/keyboard.ts +36 -27
  40. package/src/domains/navigate.ts +16 -23
  41. package/src/domains/network.ts +60 -10
  42. package/src/domains/page.ts +24 -3
  43. package/src/domains/readpage/read-page.ts +74 -19
  44. package/src/domains/readpage/readability.ts +0 -24
  45. package/src/domains/ref-resolve.ts +24 -58
  46. package/src/domains/render.ts +6 -27
  47. package/src/domains/screenshot-capture.ts +80 -0
  48. package/src/domains/screenshot.ts +9 -86
  49. package/src/domains/scroll.ts +10 -11
  50. package/src/domains/search/google-serp.ts +0 -29
  51. package/src/domains/search/web-search.ts +48 -20
  52. package/src/domains/setup.ts +1 -11
  53. package/src/domains/snapshot.ts +35 -276
  54. package/src/domains/tabs.ts +14 -7
  55. package/src/domains/target.ts +35 -0
  56. package/src/domains/viewport.ts +5 -3
  57. package/src/index.ts +8 -58
  58. package/src/profile/bind.ts +8 -57
  59. package/src/profile/browser-process.ts +75 -126
  60. package/src/profile/command.ts +50 -29
  61. package/src/profile/launch.ts +22 -40
  62. package/src/profile/list.ts +14 -54
  63. package/src/profile/paths.ts +2 -65
  64. package/src/profile/store.ts +35 -56
  65. package/src/registry.ts +30 -33
  66. package/src/schemas/parse.ts +23 -0
  67. package/src/setup.ts +0 -25
  68. package/src/state.ts +16 -16
  69. package/src/util/debug.ts +8 -0
  70. package/src/util/guards.ts +30 -0
  71. package/src/util/js-template.ts +1 -16
  72. package/src/util/mutex.ts +0 -5
  73. package/src/util/result.ts +0 -6
  74. package/src/util/sharp-shim.ts +12 -19
  75. package/src/util/tool.ts +17 -42
  76. package/src/util/truncate.ts +6 -0
  77. package/tsconfig.json +1 -16
  78. package/skills/deep-research/SKILL.md +0 -66
  79. package/src/cdp/event-queue.ts +0 -128
  80. package/src/cdp/transport.ts +0 -156
  81. package/src/cdp/window-binding.ts +0 -15
  82. package/src/deep-research.ts +0 -41
  83. package/src/domains/forms.ts +0 -355
  84. package/src/domains/readpage/capture.ts +0 -46
  85. package/src/domains/search/extract.ts +0 -44
  86. package/src/profile/picker.ts +0 -73
  87. package/src/util/keycodes.ts +0 -10
  88. package/src/util/time.ts +0 -29
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { type Result, err, ok } from "../util/result";
3
3
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { cdpCallBrowser } from "./cdp-call";
4
5
 
5
6
  const ListTabsArgs = Type.Object({
6
7
  includeInternal: Type.Optional(Type.Boolean({ default: true, description: "Include chrome:// pages" })),
@@ -24,6 +25,7 @@ export const listTabsTool = defineBrowserTool({
24
25
  "Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.",
25
26
  ],
26
27
  parameters: ListTabsArgs,
28
+ concurrency: "parallel",
27
29
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
28
30
  const r = await client.listTabs(args.includeInternal ?? true);
29
31
  if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -50,12 +52,13 @@ export const currentTabTool = defineBrowserTool({
50
52
  promptSnippet: "Get current tab info",
51
53
  promptGuidelines: ["Returns targetId, url, title, and whether the tab is owned by this harness session."],
52
54
  parameters: Type.Object({}),
55
+ concurrency: "parallel",
53
56
  async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
54
57
  const cur = client.current();
55
58
  if (!cur) return err({ kind: "invalid_state", message: "No tab attached." });
56
- const ti = await client.session().callBrowser("Target.getTargetInfo", { targetId: cur.targetId });
57
- if (!ti.success) return err({ kind: "cdp_error", message: ti.error.message });
58
- const info = (ti.data as { targetInfo: { targetId: string; url: string; title: string } }).targetInfo; // CDP boundary cast: Target.getTargetInfo returns { targetInfo: { targetId, url, title } }
59
+ const ti = await cdpCallBrowser(client, "Target.getTargetInfo", { targetId: cur.targetId });
60
+ if (!ti.success) return ti;
61
+ const info = ti.data.targetInfo;
59
62
  const owned = client.owns(info.targetId);
60
63
  return ok({
61
64
  text: `Current tab${owned ? " (owned)" : " (NOT owned by this session)"}:\n ${info.targetId}\n ${info.url}\n ${info.title}`,
@@ -83,9 +86,8 @@ export const switchTabTool = defineBrowserTool({
83
86
  "Refuses tabs not owned by this session — use browser_new_tab to open a controllable tab instead.",
84
87
  ],
85
88
  parameters: SwitchTabArgs,
89
+ concurrency: "serialized",
86
90
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
87
- // Resolve to a concrete targetId (exact, or unique hex prefix). We resolve
88
- // first so the ownership check sees the canonical id, not the prefix.
89
91
  let resolved = args.targetId;
90
92
  if (!client.owns(resolved)) {
91
93
  const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
@@ -104,7 +106,9 @@ export const switchTabTool = defineBrowserTool({
104
106
  details: { matches: matches.map((t) => ({ targetId: t.targetId, url: t.url })) },
105
107
  });
106
108
  }
107
- resolved = matches[0]!.targetId;
109
+ const first = matches[0];
110
+ if (first === undefined) return err({ kind: "invalid_state", message: "no matching tab" });
111
+ resolved = first.targetId;
108
112
  }
109
113
  if (!client.owns(resolved)) {
110
114
  return err({
@@ -139,6 +143,7 @@ export const newTabTool = defineBrowserTool({
139
143
  "Pass url to navigate immediately.",
140
144
  ],
141
145
  parameters: NewTabArgs,
146
+ concurrency: "serialized",
142
147
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
143
148
  const r = await client.newTab(args.url);
144
149
  if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -165,6 +170,7 @@ export const closeTabTool = defineBrowserTool({
165
170
  "Accepts exact targetId or a unique hex prefix (>=8 chars).",
166
171
  ],
167
172
  parameters: CloseTabArgs,
173
+ concurrency: "serialized",
168
174
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
169
175
  let resolved = args.targetId;
170
176
  if (!client.owns(resolved)) {
@@ -172,7 +178,8 @@ export const closeTabTool = defineBrowserTool({
172
178
  if (isHexPrefix) {
173
179
  const owned = client.ownership().list();
174
180
  const matches = owned.filter((id) => id.startsWith(args.targetId));
175
- if (matches.length === 1) resolved = matches[0]!;
181
+ const first = matches[0];
182
+ if (matches.length === 1 && first !== undefined) resolved = first;
176
183
  else if (matches.length > 1) {
177
184
  return err({
178
185
  kind: "invalid_state",
@@ -0,0 +1,35 @@
1
+ import type { BrowserClient } from "../client";
2
+ import { type Result, err, ok } from "../util/result";
3
+ import type { ToolErr } from "../util/tool";
4
+ import { resolveRefToBox } from "./ref-resolve";
5
+
6
+ export type PointArgs = {
7
+ readonly ref?: string | undefined;
8
+ readonly x?: number | undefined;
9
+ readonly y?: number | undefined;
10
+ };
11
+
12
+ export type ResolvedTarget = {
13
+ readonly x: number;
14
+ readonly y: number;
15
+ readonly label: string;
16
+ };
17
+
18
+ export const resolveTarget = async (
19
+ client: BrowserClient,
20
+ args: PointArgs,
21
+ ): Promise<Result<ResolvedTarget, ToolErr>> => {
22
+ if (args.ref !== undefined) {
23
+ const box = await resolveRefToBox(client, args.ref);
24
+ if (!box.success) return box;
25
+ return ok({
26
+ x: box.data.cx,
27
+ y: box.data.cy,
28
+ label: `[${args.ref}] (${box.data.cx}, ${box.data.cy})`,
29
+ });
30
+ }
31
+ if (args.x !== undefined && args.y !== undefined) {
32
+ return ok({ x: args.x, y: args.y, label: `(${args.x}, ${args.y})` });
33
+ }
34
+ return err({ kind: "invalid_state", message: "Provide either `ref` or both `x` and `y`." });
35
+ };
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
- import { type Result, err, ok } from "../util/result";
2
+ import { type Result, ok } from "../util/result";
3
3
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { cdpCall } from "./cdp-call";
4
5
 
5
6
  const ViewportArgs = Type.Object({
6
7
  width: Type.Integer({ minimum: 100, maximum: 8000, description: "Viewport CSS pixel width" }),
@@ -20,14 +21,15 @@ export const viewportResizeTool = defineBrowserTool({
20
21
  "Pass deviceScaleFactor=2 to simulate retina displays.",
21
22
  ],
22
23
  parameters: ViewportArgs,
24
+ concurrency: "serialized",
23
25
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
24
- const r = await client.session().call("Emulation.setDeviceMetricsOverride", {
26
+ const r = await cdpCall(client, "Emulation.setDeviceMetricsOverride", {
25
27
  width: args.width,
26
28
  height: args.height,
27
29
  deviceScaleFactor: args.deviceScaleFactor ?? 1,
28
30
  mobile: false,
29
31
  });
30
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
32
+ if (!r.success) return r;
31
33
  return ok({
32
34
  text: `Viewport set to ${args.width}x${args.height} @${args.deviceScaleFactor ?? 1}x`,
33
35
  details: {
package/src/index.ts CHANGED
@@ -1,41 +1,18 @@
1
- /**
2
- * pi-browser-harness — browser control extension for pi.
3
- *
4
- * Gives pi agents full control of a real Chrome browser via CDP.
5
- * Connects to the user's running Chrome (chrome://inspect/#remote-debugging),
6
- * registers browser_* tools, and injects browser control guidance into the
7
- * system prompt.
8
- *
9
- * Install:
10
- * pi install npm:pi-browser-harness
11
- * # or copy to .pi/extensions/pi-browser-harness/
12
- *
13
- * Commands:
14
- * /browser-setup — guided setup wizard
15
- * /browser-profile — choose which browser profile the agent uses
16
- * /browser-status — show client status and current page
17
- * /browser-reload-daemon — restart the browser client
18
- * /deep-research <q> — research a question on the web, write a cited report
19
- *
20
- * Flags:
21
- * --browser-namespace <name> — override namespace (default: auto)
22
- * --browser-debug-clicks — enable debug click overlay
23
- */
24
-
25
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
26
2
  import { type BrowserClient, createBrowserClient } from "./client";
27
3
  import { getBrowserSystemPrompt } from "./prompt";
28
4
  import { registerSetupCommand } from "./setup";
29
5
  import { registerProfileCommand } from "./profile/command";
30
6
  import { readPin } from "./profile/store";
31
- import { registerDeepResearchCommand } from "./deep-research";
32
7
  import { type BrowserState, defaultState, persistState, restoreState } from "./state";
33
8
  import { registerAllTools } from "./registry";
34
9
  import { cleanupTempDirs } from "./util/truncate";
35
- import { createDaemonTransport } from "./cdp/daemon-transport";
10
+ import { createDaemonTransport } from "./daemon/transport";
11
+ import { setDebugClicks } from "./util/debug";
12
+ import { asBoolean, asString } from "./util/guards";
36
13
 
37
14
  export default function browserHarnessExtension(pi: ExtensionAPI): void {
38
- const flagNs = pi.getFlag("browser-namespace") as string | undefined;
15
+ const flagNs = asString(pi.getFlag("browser-namespace"));
39
16
  const namespace = flagNs ?? `pi-${Math.random().toString(36).slice(2, 10)}`;
40
17
 
41
18
  let state: BrowserState = defaultState(namespace);
@@ -106,16 +83,11 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
106
83
 
107
84
  pi.on("session_start", async (_event, ctx) => {
108
85
  state = restoreState(ctx, state.namespace);
86
+ setDebugClicks(asBoolean(pi.getFlag("browser-debug-clicks")) ?? false);
109
87
 
110
- // The pinned profile lives on disk, not in the session, so it survives
111
- // session termination. Re-read it every session start: another pi session
112
- // may have changed it since this client was created.
88
+ // Re-read the on-disk profile pin every session start: another pi session may have changed it since this client was created.
113
89
  const profilePin = await readPin();
114
90
 
115
- // Create the client lazily on first session. The daemon transport
116
- // is created but NOT connected — that only happens when the user
117
- // runs /browser-setup or a browser tool call finds an existing socket.
118
- // This means zero Chrome prompts for non-browser pi sessions.
119
91
  if (!client) {
120
92
  const transport = createDaemonTransport(state.namespace);
121
93
 
@@ -140,53 +112,31 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
140
112
  ? { harnessWindowId: snap.harnessWindowId }
141
113
  : {}),
142
114
  };
143
- // Ownership changes can fire from CDP events at any time,
144
- // including after session replacement when pi's ctx is stale.
145
- // Persistence is best-effort — failure must not crash the event consumer.
146
- // ponytail: try/catch is the simplest guard against stale-ctx.
147
- try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ }
115
+ try { persistState(pi, state); } catch {}
148
116
  },
149
117
  });
150
118
  } else {
151
- // Client carried over from a previous session — adopt whatever pin is on
152
- // disk now, which another pi session may have changed.
153
119
  client.setProfilePin(profilePin);
154
120
  }
155
121
 
156
- // Do NOT call client.start() here — browser control is on-demand.
157
- // The first browser tool call or /browser-setup triggers the actual
158
- // daemon socket connection and Chrome prompt.
159
-
160
122
  if (!toolsRegistered) {
161
123
  registerAllTools(pi, client);
162
124
  toolsRegistered = true;
163
125
  }
164
126
  registerSetupCommand(pi, client);
165
127
  registerProfileCommand(pi, client);
166
- registerDeepResearchCommand(pi);
167
128
  });
168
129
 
169
130
  pi.on("session_shutdown", async () => {
170
131
  if (client) {
171
132
  try {
172
- // Detach from the page target (removes the "Chrome is being controlled"
173
- // banner) but keep the transport alive for the next session.
174
133
  await client.detach();
175
- // Close every tab this session opened so no stale harness tabs linger
176
- // in the user's browser between sessions. This also clears the persisted
177
- // ownership + window binding via onOwnershipChange, so the next session
178
- // starts with a fresh dedicated window rather than reattaching to leftovers.
179
134
  await client.closeOwnedTabs();
180
135
  } catch (e) {
181
136
  console.warn("[pi-browser-harness] browser teardown failed during shutdown:", e);
182
137
  }
183
- // ponytail: keep the transport alive across sessions.
184
- // Do NOT call client.stop() or null out client — the daemon connection
185
- // persists and eliminates the per-session "Allow Remote Debugging" prompt.
186
- // Only Chrome restart or daemon death triggers a new prompt.
138
+ // Do NOT stop the client or null it out: the surviving daemon connection is what eliminates the per-session "Allow Remote Debugging" prompt.
187
139
  }
188
- // Persist last — closeOwnedTabs mutates `state` through onOwnershipChange,
189
- // so this captures the cleared ownership set.
190
140
  persistState(pi, state);
191
141
  await cleanupTempDirs();
192
142
  });
@@ -1,62 +1,30 @@
1
- /**
2
- * Binding the harness session to a pinned profile.
3
- *
4
- * Sequence, all of it verified against a live browser:
5
- * 1. resolve the browser binary and any explicit --user-data-dir
6
- * 2. launch a window into the profile, marked with a file:// sentinel
7
- * 3. poll Target.getTargets for the sentinel page — its browserContextId IS
8
- * the profile's context, which is otherwise undiscoverable (CDP exposes no
9
- * profile identity)
10
- * 4. adopt that page as the harness window, blank it, drop the sentinel file
11
- *
12
- * If the sentinel never appears the bind FAILS LOUDLY. Falling through to a
13
- * normal createTarget would put the agent in the focus-derived default profile
14
- * — the exact bug this feature exists to remove — so the caller gets an
15
- * actionable error instead.
16
- *
17
- * The resolved context id is session-scoped state, never persisted: Chrome
18
- * mints new context ids on every browser run.
19
- */
20
-
21
1
  import type { BrowserClient } from "../client";
2
+ import type { ResultOf } from "../cdp/commands";
22
3
  import { type CdpError, cdpError } from "../cdp/errors";
23
4
  import { type Result, err, ok } from "../util/result";
24
5
  import { detectRunningBrowser, resolveBrowserExecutable } from "./browser-process";
25
6
  import { openProfileWindow } from "./launch";
26
7
  import type { ProfilePin } from "./store";
27
8
 
28
- /** The launch delegates through ProcessSingleton, so allow for a cold profile. */
29
9
  const SEED_DEADLINE_MS = 15_000;
30
10
  const SEED_POLL_MS = 500;
31
11
 
32
- type PageTarget = {
33
- readonly targetId: string;
34
- readonly type: string;
35
- readonly url: string;
36
- readonly browserContextId?: string;
37
- };
12
+ type PageTarget = ResultOf<"Target.getTargets">["targetInfos"][number];
38
13
 
39
14
  const pageTargets = async (client: BrowserClient): Promise<ReadonlyArray<PageTarget>> => {
40
15
  const r = await client.session().callBrowser("Target.getTargets");
41
16
  if (!r.success) return [];
42
- const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
43
- return data.targetInfos.filter((t) => t.type === "page");
17
+ return r.data.targetInfos.filter((t) => t.type === "page");
44
18
  };
45
19
 
46
20
  const manualFallbackHint = (pin: ProfilePin): string =>
47
21
  `open a window for "${pin.label}" yourself (browser profile menu → ${pin.profileDir}), then retry. ` +
48
22
  `Run /browser-profile to pick a different profile, or clear the selection to use whichever window is focused.`;
49
23
 
50
- /**
51
- * Open (and adopt) a harness window inside the pinned profile. Returns the
52
- * seed tab's target id.
53
- */
54
24
  export const seedProfileWindow = async (
55
25
  client: BrowserClient,
56
26
  pin: ProfilePin,
57
27
  ): Promise<Result<string, CdpError>> => {
58
- // A pin belongs to one user-data-dir. If the harness is now attached to a
59
- // different browser, silently seeding the "same" profile name would be wrong.
60
28
  const connectedDir = client.userDataDir();
61
29
  if (connectedDir && connectedDir !== pin.userDataDir) {
62
30
  return err(cdpError(
@@ -66,9 +34,6 @@ export const seedProfileWindow = async (
66
34
  ));
67
35
  }
68
36
 
69
- // Identify the browser by the dir we're connected to. With several Chromium
70
- // browsers running, launching into the wrong one would open a window the
71
- // harness cannot see — in a browser the user never pointed us at.
72
37
  const targetUserDataDir = connectedDir ?? pin.userDataDir;
73
38
  const running = await detectRunningBrowser(targetUserDataDir);
74
39
  const exePath = await resolveBrowserExecutable(running);
@@ -82,11 +47,7 @@ export const seedProfileWindow = async (
82
47
  const launched = await openProfileWindow({
83
48
  exePath,
84
49
  profileDir: pin.profileDir,
85
- // Forward the user-data-dir ONLY when the running browser advertises one.
86
- // ProcessSingleton keys on this path: passing the value the target process
87
- // itself reports guarantees delegation, whereas supplying a default dir we
88
- // reconstructed could differ by case or normalisation and start a SECOND
89
- // browser instead.
50
+ // ProcessSingleton keys on the user-data-dir path, so forward only the value the running browser reports: a reconstructed one may differ by case and start a SECOND browser.
90
51
  ...(running.explicitUserDataDir ? { explicitUserDataDir: running.explicitUserDataDir } : {}),
91
52
  });
92
53
  if (!launched.success) {
@@ -116,22 +77,12 @@ export const seedProfileWindow = async (
116
77
  const ownership = client.ownership();
117
78
  ownership.setHarnessWindow(seed.targetId);
118
79
  ownership.add(seed.targetId);
119
- const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId: seed.targetId });
120
- if (win.success) {
121
- const windowId = (win.data as { windowId?: number }).windowId;
122
- if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
123
- }
80
+ const win = await client.session().windowId(seed.targetId);
81
+ if (win.success) ownership.setHarnessWindowId(win.data);
124
82
 
125
- // Blank the handshake page so the seed tab behaves like any other fresh tab,
126
- // then remove the sentinel file. Best-effort: a failure here costs nothing
127
- // beyond the placeholder page staying visible.
128
- const attached = await client.session().callBrowser("Target.attachToTarget", {
129
- targetId: seed.targetId,
130
- flatten: true,
131
- });
83
+ const attached = await client.session().attach(seed.targetId);
132
84
  if (attached.success) {
133
- const sessionId = (attached.data as { sessionId: string }).sessionId;
134
- await client.session().callOnTarget("Page.navigate", { url: "about:blank" }, sessionId);
85
+ await client.session().callOnTarget("Page.navigate", { url: "about:blank" }, attached.data);
135
86
  }
136
87
  await cleanup();
137
88