pi-browser-harness 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -1
- package/package.json +2 -1
- package/skills/pi-browser-harness/SKILL.md +17 -1
- package/src/cdp/daemon-transport.ts +277 -0
- package/src/client.ts +16 -2
- package/src/daemon/bridge.ts +411 -0
- package/src/daemon/index.ts +114 -0
- package/src/daemon/protocol.ts +162 -0
- package/src/daemon/server.ts +245 -0
- package/src/daemon/spawn.ts +85 -0
- package/src/domains/navigate.ts +17 -3
- package/src/domains/setup.ts +41 -0
- package/src/index.ts +46 -25
- package/src/registry.ts +3 -0
- package/src/setup.ts +45 -39
- package/src/util/tool.ts +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
-
## 0.
|
|
5
|
+
## 0.6.0 — 2026-06-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Per-tab isolation for multi-agent safety.** Each agent's browser session now operates in a dedicated Chrome window with tab ownership tracking. Tabs opened via `browser_open_urls` and `browser_new_tab` are automatically registered in the ownership registry and tagged with a 🟢 prefix in the document title for user visibility. `browser_list_tabs` scoped to `"owned"` (default) only shows the current session's tabs.
|
|
10
|
+
- **Unix socket daemon with auto-reconnect.** The browser daemon now binds to a Unix-domain socket (`pi-browser.sock`) in the harness temp directory instead of a TCP port. The transport layer reconnects automatically when Chrome restarts — no manual `/browser-reload-daemon` needed.
|
|
11
|
+
- **On-demand browser initialization.** Chrome is no longer launched eagerly at harness startup. The harness attaches lazily on the first browser tool call, reducing resource usage when the agent isn't using the browser.
|
|
12
|
+
- **`browser_setup` as agent-callable tool.** The setup tool can now be called programmatically by agents, not just via slash command. Idempotent — safe to call when already connected.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Chrome detection on macOS** now matches by process-name substring instead of exact binary path comparison, fixing false negatives when Chrome is running from different installation paths (e.g. `/Applications/Google Chrome.app/` vs user-local copies).
|
|
6
17
|
|
|
7
18
|
### Added
|
|
8
19
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-browser-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@mariozechner/pi-coding-agent": "*",
|
|
53
53
|
"@mariozechner/pi-tui": "*",
|
|
54
54
|
"@types/ws": "^8.18.1",
|
|
55
|
+
"tsx": "^4.22.4",
|
|
55
56
|
"typebox": "*",
|
|
56
57
|
"typescript": "^5.0.0"
|
|
57
58
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pi-browser-harness
|
|
3
|
-
description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user
|
|
3
|
+
description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user's already-running Chrome. Default to browser_snapshot for understanding pages and browser_execute_js for surgical reads — browser_screenshot is for visual verification only.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pi-browser-harness
|
|
@@ -30,6 +30,22 @@ What do you need to know?
|
|
|
30
30
|
|
|
31
31
|
Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot round-trip.
|
|
32
32
|
|
|
33
|
+
## Connection Setup
|
|
34
|
+
|
|
35
|
+
Browser control is **on-demand** — the daemon does NOT start automatically.
|
|
36
|
+
If you try a browser tool and get a `not_connected` error, tell the user to
|
|
37
|
+
run `/browser-setup` first. This opens the daemon and connects to Chrome.
|
|
38
|
+
Once initialized, all subsequent sessions reuse the same connection silently.
|
|
39
|
+
|
|
40
|
+
**Before calling any browser tool**, the runtime checks for the daemon socket
|
|
41
|
+
at `/tmp/pi-browser-daemon.sock`. If the socket is missing (user hasn't run
|
|
42
|
+
`/browser-setup`), you get: `"Browser harness not initialized. Run /browser-setup first"`.
|
|
43
|
+
|
|
44
|
+
**Do not ask the user.** Call `browser_setup` directly — it spawns the daemon,
|
|
45
|
+
connects to Chrome, and opens a test tab. The user sees a single "Allow Remote
|
|
46
|
+
Debugging" prompt the first time. After that, all sessions reuse the same connection.
|
|
47
|
+
`browser_setup` is idempotent — safe to call even when already connected.
|
|
48
|
+
|
|
33
49
|
## Connection
|
|
34
50
|
|
|
35
51
|
You're attached to the user's real Chrome — never launch your own. If auth is required, stop and ask the user. If `browser_page_info` returns a dialog, handle it first with `browser_handle_dialog`.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DaemonTransport — CdpTransport backed by a Unix socket to the browser daemon.
|
|
3
|
+
*
|
|
4
|
+
* Satisfies the exact CdpTransport interface so BrowserClient, CdpSession, and
|
|
5
|
+
* all domain tools work without changes. Internally uses newline-delimited JSON
|
|
6
|
+
* over a Unix domain socket instead of a WebSocket to Chrome.
|
|
7
|
+
*
|
|
8
|
+
* References:
|
|
9
|
+
* - CdpTransport interface: src/cdp/transport.ts
|
|
10
|
+
* - net.createConnection IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateconnection
|
|
11
|
+
* - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createConnection, type Socket } from "node:net";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
import { type Result, err, ok } from "../util/result";
|
|
17
|
+
import { type CdpError, cdpError } from "../cdp/errors";
|
|
18
|
+
import type { CdpTransport } from "../cdp/transport";
|
|
19
|
+
import type { CdpEvent } from "../cdp/types";
|
|
20
|
+
import {
|
|
21
|
+
DAEMON_SOCKET_PATH,
|
|
22
|
+
type WireRequest,
|
|
23
|
+
type WireControl,
|
|
24
|
+
deserialize,
|
|
25
|
+
serialize,
|
|
26
|
+
} from "../daemon/protocol";
|
|
27
|
+
|
|
28
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
type EventQueue = {
|
|
31
|
+
readonly push: (e: CdpEvent) => void;
|
|
32
|
+
readonly end: () => void;
|
|
33
|
+
readonly iter: AsyncIterable<CdpEvent>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const makeEventQueue = (): EventQueue => {
|
|
39
|
+
const buf: CdpEvent[] = [];
|
|
40
|
+
const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
|
|
41
|
+
let ended = false;
|
|
42
|
+
return {
|
|
43
|
+
push(e) {
|
|
44
|
+
if (ended) return;
|
|
45
|
+
const w = waiters.shift();
|
|
46
|
+
if (w) w({ value: e, done: false });
|
|
47
|
+
else buf.push(e);
|
|
48
|
+
},
|
|
49
|
+
end() {
|
|
50
|
+
ended = true;
|
|
51
|
+
for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
|
|
52
|
+
},
|
|
53
|
+
iter: {
|
|
54
|
+
[Symbol.asyncIterator]() {
|
|
55
|
+
return {
|
|
56
|
+
next: (): Promise<IteratorResult<CdpEvent>> =>
|
|
57
|
+
new Promise((resolve) => {
|
|
58
|
+
const next = buf.shift();
|
|
59
|
+
if (next) resolve({ value: next, done: false });
|
|
60
|
+
else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
|
|
61
|
+
else waiters.push(resolve);
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── Implementation ─────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export const createDaemonTransport = (clientId: string): CdpTransport => {
|
|
72
|
+
let socket: Socket | null = null;
|
|
73
|
+
let rl: ReturnType<typeof createInterface> | null = null;
|
|
74
|
+
let queue = makeEventQueue();
|
|
75
|
+
const closeListeners = new Set<() => void>();
|
|
76
|
+
const pending = new Map<number, {
|
|
77
|
+
resolve: (v: Result<unknown, CdpError>) => void;
|
|
78
|
+
timer: ReturnType<typeof setTimeout>;
|
|
79
|
+
method: string;
|
|
80
|
+
}>();
|
|
81
|
+
let registered = false;
|
|
82
|
+
let nextRequestId = 1;
|
|
83
|
+
|
|
84
|
+
const cleanup = (reason: string): void => {
|
|
85
|
+
for (const [, p] of pending) {
|
|
86
|
+
clearTimeout(p.timer);
|
|
87
|
+
p.resolve(err(cdpError("transport_closed", reason, p.method)));
|
|
88
|
+
}
|
|
89
|
+
pending.clear();
|
|
90
|
+
queue.end();
|
|
91
|
+
queue = makeEventQueue();
|
|
92
|
+
registered = false;
|
|
93
|
+
|
|
94
|
+
if (rl) { rl.close(); rl = null; }
|
|
95
|
+
if (socket) { try { socket.destroy(); } catch {} socket = null; }
|
|
96
|
+
|
|
97
|
+
for (const cb of closeListeners) cb();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ── Connect ────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => {
|
|
103
|
+
// url is ignored — we always connect to the daemon socket
|
|
104
|
+
const timeoutMs = opts?.timeoutMs ?? 10_000;
|
|
105
|
+
|
|
106
|
+
if (socket && !socket.destroyed && registered) {
|
|
107
|
+
return Promise.resolve(ok(undefined));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
let settled = false;
|
|
112
|
+
const settle = (r: Result<void, CdpError>): void => {
|
|
113
|
+
if (settled) return;
|
|
114
|
+
settled = true;
|
|
115
|
+
resolve(r);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
socket = createConnection(DAEMON_SOCKET_PATH);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const connectTimer = setTimeout(() => {
|
|
126
|
+
cleanup("Connection timeout");
|
|
127
|
+
settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`)));
|
|
128
|
+
}, timeoutMs);
|
|
129
|
+
|
|
130
|
+
socket.on("connect", () => {
|
|
131
|
+
clearTimeout(connectTimer);
|
|
132
|
+
rl = createInterface({ input: socket!, crlfDelay: Infinity });
|
|
133
|
+
|
|
134
|
+
rl.on("line", (line: string) => {
|
|
135
|
+
const msg = deserialize(line);
|
|
136
|
+
if (!msg) return;
|
|
137
|
+
|
|
138
|
+
if (msg.type === "control" && msg.action === "registered" && msg.clientId === clientId) {
|
|
139
|
+
registered = true;
|
|
140
|
+
settle(ok(undefined));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (msg.type === "control" && msg.action === "shutdown") {
|
|
145
|
+
cleanup(`Daemon shutting down: ${msg.reason ?? "unknown"}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!registered) return;
|
|
150
|
+
|
|
151
|
+
if (msg.type === "response") {
|
|
152
|
+
const p = pending.get(msg.id);
|
|
153
|
+
if (!p) return;
|
|
154
|
+
pending.delete(msg.id);
|
|
155
|
+
clearTimeout(p.timer);
|
|
156
|
+
|
|
157
|
+
if (msg.error) {
|
|
158
|
+
p.resolve(err(cdpError(
|
|
159
|
+
msg.error.message.includes("Session with given id not found")
|
|
160
|
+
? "session_not_found"
|
|
161
|
+
: "remote_error",
|
|
162
|
+
msg.error.message,
|
|
163
|
+
p.method,
|
|
164
|
+
)));
|
|
165
|
+
} else {
|
|
166
|
+
p.resolve(ok(msg.result));
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (msg.type === "event") {
|
|
172
|
+
queue.push({
|
|
173
|
+
method: msg.method,
|
|
174
|
+
params: msg.params,
|
|
175
|
+
...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
socket!.on("error", () => {
|
|
182
|
+
// Errors are surfaced via 'close'
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
socket!.on("close", () => {
|
|
186
|
+
clearTimeout(connectTimer);
|
|
187
|
+
cleanup("Daemon socket closed");
|
|
188
|
+
// If connect hasn't settled yet, settle with error
|
|
189
|
+
settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Send registration
|
|
193
|
+
const regMsg: WireControl = { type: "control", action: "register", clientId };
|
|
194
|
+
socket!.write(serialize(regMsg) + "\n");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
socket.on("error", (e: NodeJS.ErrnoException) => {
|
|
198
|
+
clearTimeout(connectTimer);
|
|
199
|
+
const msg = e.code === "ENOENT"
|
|
200
|
+
? "Daemon not running — socket not found"
|
|
201
|
+
: e.message;
|
|
202
|
+
settle(err(cdpError("transport_closed", msg)));
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// ── Close ──────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const close = async (): Promise<void> => {
|
|
210
|
+
if (registered && socket && !socket.destroyed) {
|
|
211
|
+
const dereg: WireControl = { type: "control", action: "deregister", clientId };
|
|
212
|
+
try { socket.write(serialize(dereg) + "\n"); } catch {}
|
|
213
|
+
}
|
|
214
|
+
cleanup("close() called");
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// ── Request ────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
const request = (
|
|
220
|
+
method: string,
|
|
221
|
+
params: Record<string, unknown>,
|
|
222
|
+
opts?: { sessionId?: string | null; timeoutMs?: number },
|
|
223
|
+
): Promise<Result<unknown, CdpError>> => {
|
|
224
|
+
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
|
225
|
+
const sessionId = opts?.sessionId ?? null;
|
|
226
|
+
|
|
227
|
+
if (!socket || socket.destroyed || !registered) {
|
|
228
|
+
return Promise.resolve(err(cdpError("transport_closed", "Daemon not connected", method)));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const id = nextRequestId++;
|
|
232
|
+
const req: WireRequest = {
|
|
233
|
+
type: "request",
|
|
234
|
+
id,
|
|
235
|
+
method,
|
|
236
|
+
params,
|
|
237
|
+
...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
const timer = setTimeout(() => {
|
|
242
|
+
pending.delete(id);
|
|
243
|
+
resolve(err(cdpError("timeout", `Daemon timeout after ${timeoutMs}ms: ${method}`, method)));
|
|
244
|
+
}, timeoutMs);
|
|
245
|
+
|
|
246
|
+
pending.set(id, { resolve, timer, method });
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
socket!.write(serialize(req) + "\n");
|
|
250
|
+
} catch (e) {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
pending.delete(id);
|
|
253
|
+
resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// ── Events ─────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
const events = (): AsyncIterable<CdpEvent> => queue.iter;
|
|
261
|
+
|
|
262
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
const state = (): "open" | "closed" | "connecting" => {
|
|
265
|
+
if (!socket) return "closed";
|
|
266
|
+
if (!registered) return "connecting";
|
|
267
|
+
if (socket.destroyed) return "closed";
|
|
268
|
+
return "open";
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const onClose = (cb: () => void): (() => void) => {
|
|
272
|
+
closeListeners.add(cb);
|
|
273
|
+
return () => closeListeners.delete(cb);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return { connect, close, request, events, state, onClose };
|
|
277
|
+
};
|
package/src/client.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
|
|
|
11
11
|
|
|
12
12
|
export type BrowserClientOptions = {
|
|
13
13
|
readonly namespace: string;
|
|
14
|
+
readonly transport?: CdpTransport;
|
|
14
15
|
readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
|
|
15
16
|
readonly initialOwnership?: {
|
|
16
17
|
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
@@ -28,6 +29,9 @@ export type BrowserClient = {
|
|
|
28
29
|
status(): DaemonStatus;
|
|
29
30
|
start(): Promise<Result<void, CdpError>>;
|
|
30
31
|
stop(): Promise<void>;
|
|
32
|
+
/** Detach from the current page target (removes the "Chrome is being controlled" banner)
|
|
33
|
+
* while keeping the transport connection alive. Call on session shutdown. */
|
|
34
|
+
detach(): Promise<void>;
|
|
31
35
|
evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
|
|
32
36
|
pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
|
|
33
37
|
takeDialog(): DialogInfo | null;
|
|
@@ -77,7 +81,7 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
|
|
|
77
81
|
};
|
|
78
82
|
|
|
79
83
|
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
|
|
80
|
-
const transport = createCdpTransport();
|
|
84
|
+
const transport = opts.transport ?? createCdpTransport();
|
|
81
85
|
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
|
|
82
86
|
if (opts.initialOwnership?.ownedTargetIds !== undefined) {
|
|
83
87
|
ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
|
|
@@ -298,9 +302,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
298
302
|
...(remote?.browserId !== undefined ? { remoteBrowserId: remote.browserId } : {}),
|
|
299
303
|
});
|
|
300
304
|
|
|
305
|
+
const detach = async (): Promise<void> => {
|
|
306
|
+
const cur = session.current();
|
|
307
|
+
if (!cur) return;
|
|
308
|
+
// Target.detachFromTarget removes the "Chrome is being controlled" banner
|
|
309
|
+
// and releases the page session. The transport (WebSocket/Unix socket)
|
|
310
|
+
// stays alive for reuse by the next session.
|
|
311
|
+
// Reference: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-detachFromTarget
|
|
312
|
+
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
|
|
313
|
+
};
|
|
314
|
+
|
|
301
315
|
return {
|
|
302
316
|
namespace: opts.namespace,
|
|
303
|
-
ensureAlive, status, start, stop,
|
|
317
|
+
ensureAlive, status, start, stop, detach,
|
|
304
318
|
evaluateJs, pageInfo,
|
|
305
319
|
takeDialog: () => session.takeDialog(),
|
|
306
320
|
listTabs, switchTab, newTab, closeTab,
|