pi-browser-harness 0.4.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 +27 -0
- package/README.md +150 -56
- package/package.json +2 -1
- package/skills/pi-browser-harness/SKILL.md +24 -1
- package/src/cdp/console-buffer.ts +263 -0
- package/src/cdp/daemon-transport.ts +277 -0
- package/src/cdp/session.ts +124 -23
- package/src/client.ts +29 -10
- 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/console.ts +173 -0
- package/src/domains/navigate.ts +17 -3
- package/src/domains/setup.ts +41 -0
- package/src/index.ts +46 -25
- package/src/prompt.ts +3 -0
- package/src/registry.ts +5 -0
- package/src/setup.ts +45 -39
- package/src/util/tool.ts +19 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IpcServer — Unix socket server for daemon ↔ pi client communication.
|
|
3
|
+
*
|
|
4
|
+
* Accepts multiple concurrent pi client connections over a Unix domain
|
|
5
|
+
* stream socket. Uses newline-delimited JSON framing (one WireMessage per
|
|
6
|
+
* line). Clients are anonymous until they send a control.register message.
|
|
7
|
+
*
|
|
8
|
+
* References:
|
|
9
|
+
* - net.createServer: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateserveroptions-connectionlistener
|
|
10
|
+
* - server.listen(path): https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
|
|
11
|
+
* - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
15
|
+
import { createInterface, type Interface } from "node:readline";
|
|
16
|
+
import { unlinkSync } from "node:fs";
|
|
17
|
+
import {
|
|
18
|
+
type WireMessage,
|
|
19
|
+
type WireControl,
|
|
20
|
+
isWireMessage,
|
|
21
|
+
DAEMON_SOCKET_PATH,
|
|
22
|
+
DAEMON_MAX_CLIENTS,
|
|
23
|
+
DAEMON_STALE_SOCKET_CLEANUP,
|
|
24
|
+
} from "./protocol";
|
|
25
|
+
|
|
26
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** A connected pi client with its socket and readline interface. */
|
|
29
|
+
export type ClientSocket = {
|
|
30
|
+
/** Client namespace (set after registration). */
|
|
31
|
+
id: string;
|
|
32
|
+
readonly socket: Socket;
|
|
33
|
+
readonly rl: Interface;
|
|
34
|
+
/** True once a valid control.register message has been received. */
|
|
35
|
+
registered: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Handler called for every valid WireMessage from any client. */
|
|
39
|
+
export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void;
|
|
40
|
+
|
|
41
|
+
/** Handler called when a client connects or disconnects. */
|
|
42
|
+
export type ConnectionHandler = (client: ClientSocket) => void;
|
|
43
|
+
|
|
44
|
+
export type IpcServer = {
|
|
45
|
+
/** Start listening on the Unix socket. Resolves when the server is ready. */
|
|
46
|
+
start(): Promise<void>;
|
|
47
|
+
/** Close the server and all client connections. Resolves when cleaned up. */
|
|
48
|
+
stop(): Promise<void>;
|
|
49
|
+
/** Register a handler for incoming WireMessages from any client. */
|
|
50
|
+
onMessage(handler: MessageHandler): void;
|
|
51
|
+
/** Register a handler for client connect events. */
|
|
52
|
+
onConnect(handler: ConnectionHandler): void;
|
|
53
|
+
/** Register a handler for client disconnect events. */
|
|
54
|
+
onDisconnect(handler: ConnectionHandler): void;
|
|
55
|
+
/** Send a WireMessage to a specific client by clientId. No-op if client not found. */
|
|
56
|
+
send(clientId: string, msg: WireMessage): void;
|
|
57
|
+
/** Send a WireMessage to all connected (registered) clients. */
|
|
58
|
+
broadcast(msg: WireMessage): void;
|
|
59
|
+
/** Force-disconnect a client by clientId. */
|
|
60
|
+
disconnectClient(clientId: string): void;
|
|
61
|
+
/** All currently connected clients (by clientId). */
|
|
62
|
+
clients(): ReadonlyMap<string, ClientSocket>;
|
|
63
|
+
/** Number of connected clients. */
|
|
64
|
+
clientCount(): number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ── Implementation ─────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export const createIpcServer = (): IpcServer => {
|
|
70
|
+
let server: Server | null = null;
|
|
71
|
+
// Anonymous temp-id → ClientSocket. Temp ids are assigned before registration.
|
|
72
|
+
const sockets = new Map<string, ClientSocket>();
|
|
73
|
+
// clientId → ClientSocket (only registered clients).
|
|
74
|
+
const clients = new Map<string, ClientSocket>();
|
|
75
|
+
let messageHandler: MessageHandler | null = null;
|
|
76
|
+
let connectHandler: ConnectionHandler | null = null;
|
|
77
|
+
let disconnectHandler: ConnectionHandler | null = null;
|
|
78
|
+
let nextTempId = 1;
|
|
79
|
+
|
|
80
|
+
const start = (): Promise<void> =>
|
|
81
|
+
new Promise((resolve, reject) => {
|
|
82
|
+
// Clean up stale socket file from a previous crashed daemon.
|
|
83
|
+
if (DAEMON_STALE_SOCKET_CLEANUP) {
|
|
84
|
+
try {
|
|
85
|
+
unlinkSync(DAEMON_SOCKET_PATH);
|
|
86
|
+
} catch {
|
|
87
|
+
// ENOENT is expected — no stale socket to clean.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
|
|
92
|
+
// Reject if at capacity (check registered clients only).
|
|
93
|
+
if (clients.size >= DAEMON_MAX_CLIENTS) {
|
|
94
|
+
socket.destroy();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tempId = `anon-${nextTempId++}`;
|
|
99
|
+
const rl = createInterface({ input: socket, crlfDelay: Infinity });
|
|
100
|
+
let currentClient: ClientSocket = { id: tempId, socket, rl, registered: false };
|
|
101
|
+
|
|
102
|
+
sockets.set(tempId, currentClient);
|
|
103
|
+
|
|
104
|
+
// Buffer for partial lines. readline handles line splitting, but
|
|
105
|
+
// we store a reference in case we need it for diagnostics.
|
|
106
|
+
let lineCount = 0;
|
|
107
|
+
|
|
108
|
+
rl.on("line", (line: string) => {
|
|
109
|
+
lineCount++;
|
|
110
|
+
let msg: WireMessage | null;
|
|
111
|
+
try {
|
|
112
|
+
const parsed: unknown = JSON.parse(line);
|
|
113
|
+
if (!isWireMessage(parsed)) return;
|
|
114
|
+
msg = parsed;
|
|
115
|
+
} catch {
|
|
116
|
+
// Malformed JSON — ignore silently, don't crash the daemon.
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Handle registration before forwarding to the general handler.
|
|
121
|
+
if (msg.type === "control" && msg.action === "register" && !currentClient.registered) {
|
|
122
|
+
const clientId = msg.clientId;
|
|
123
|
+
if (!clientId) return;
|
|
124
|
+
|
|
125
|
+
// If a client with this id is already connected, reject.
|
|
126
|
+
if (clients.has(clientId)) {
|
|
127
|
+
const reply: WireControl = {
|
|
128
|
+
type: "control",
|
|
129
|
+
action: "shutdown",
|
|
130
|
+
reason: `clientId ${clientId} is already connected`,
|
|
131
|
+
};
|
|
132
|
+
socket.write(JSON.stringify(reply) + "\n");
|
|
133
|
+
socket.destroy();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Promote from anonymous to registered — mutate in place so the
|
|
138
|
+
// closure captures the updated client reference.
|
|
139
|
+
sockets.delete(tempId);
|
|
140
|
+
currentClient.id = clientId;
|
|
141
|
+
currentClient.registered = true;
|
|
142
|
+
clients.set(clientId, currentClient);
|
|
143
|
+
|
|
144
|
+
// Acknowledge registration.
|
|
145
|
+
const ack: WireControl = { type: "control", action: "registered", clientId };
|
|
146
|
+
socket.write(JSON.stringify(ack) + "\n");
|
|
147
|
+
|
|
148
|
+
connectHandler?.(currentClient);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Require registration for non-control messages.
|
|
153
|
+
if (!currentClient.registered) return;
|
|
154
|
+
|
|
155
|
+
// Forward to the general handler.
|
|
156
|
+
messageHandler?.(msg, currentClient);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
socket.on("error", () => {
|
|
160
|
+
// Socket errors (ECONNRESET, etc.) are handled by the 'close' event.
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
socket.on("close", () => {
|
|
164
|
+
// Clean up whichever map the client is in.
|
|
165
|
+
const found = clients.get(currentClient.id) ?? sockets.get(tempId);
|
|
166
|
+
if (found) {
|
|
167
|
+
clients.delete(found.id);
|
|
168
|
+
sockets.delete(tempId);
|
|
169
|
+
// Notify if this was a registered client.
|
|
170
|
+
if (found.registered) {
|
|
171
|
+
disconnectHandler?.(found);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Clean up readline to prevent memory leaks.
|
|
175
|
+
rl.close();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
180
|
+
reject(err);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
server.listen(DAEMON_SOCKET_PATH, () => {
|
|
184
|
+
resolve();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const stop = (): Promise<void> =>
|
|
189
|
+
new Promise((resolve) => {
|
|
190
|
+
// Close all client sockets.
|
|
191
|
+
for (const [, client] of clients) {
|
|
192
|
+
try { client.socket.destroy(); } catch { /* best-effort */ }
|
|
193
|
+
}
|
|
194
|
+
for (const [, client] of sockets) {
|
|
195
|
+
try { client.socket.destroy(); } catch { /* best-effort */ }
|
|
196
|
+
}
|
|
197
|
+
clients.clear();
|
|
198
|
+
sockets.clear();
|
|
199
|
+
|
|
200
|
+
if (server) {
|
|
201
|
+
server.close(() => {
|
|
202
|
+
// Clean up the socket file.
|
|
203
|
+
try { unlinkSync(DAEMON_SOCKET_PATH); } catch { /* ENOENT ok */ }
|
|
204
|
+
resolve();
|
|
205
|
+
});
|
|
206
|
+
} else {
|
|
207
|
+
resolve();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
start,
|
|
213
|
+
stop,
|
|
214
|
+
onMessage(handler) {
|
|
215
|
+
messageHandler = handler;
|
|
216
|
+
},
|
|
217
|
+
onConnect(handler) {
|
|
218
|
+
connectHandler = handler;
|
|
219
|
+
},
|
|
220
|
+
onDisconnect(handler) {
|
|
221
|
+
disconnectHandler = handler;
|
|
222
|
+
},
|
|
223
|
+
send(clientId, msg) {
|
|
224
|
+
const client = clients.get(clientId);
|
|
225
|
+
if (!client) return;
|
|
226
|
+
try {
|
|
227
|
+
client.socket.write(JSON.stringify(msg) + "\n");
|
|
228
|
+
} catch {
|
|
229
|
+
// Socket write error — the close handler will clean up.
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
broadcast(msg) {
|
|
233
|
+
for (const [clientId] of clients) {
|
|
234
|
+
this.send(clientId, msg);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
disconnectClient(clientId) {
|
|
238
|
+
const client = clients.get(clientId);
|
|
239
|
+
if (!client) return;
|
|
240
|
+
client.socket.destroy();
|
|
241
|
+
},
|
|
242
|
+
clients: (): ReadonlyMap<string, ClientSocket> => clients,
|
|
243
|
+
clientCount: (): number => clients.size,
|
|
244
|
+
};
|
|
245
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon lifecycle helpers — spawn detection and lazy launch.
|
|
3
|
+
*
|
|
4
|
+
* The daemon is a standalone long-lived process that owns the single WebSocket
|
|
5
|
+
* to Chrome. pi instances connect to it via a Unix domain socket. This module
|
|
6
|
+
* provides the plumbing to detect a running daemon and spawn one if needed.
|
|
7
|
+
*
|
|
8
|
+
* Reference:
|
|
9
|
+
* - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
13
|
+
import { access } from "node:fs/promises";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { DAEMON_SOCKET_PATH } from "./protocol";
|
|
18
|
+
|
|
19
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check whether the daemon is running by testing if its Unix socket exists.
|
|
23
|
+
* The socket file is created by the daemon on startup and removed on shutdown.
|
|
24
|
+
*/
|
|
25
|
+
export const isDaemonRunning = async (): Promise<boolean> => {
|
|
26
|
+
try {
|
|
27
|
+
await access(DAEMON_SOCKET_PATH);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Spawn the daemon as a detached child process. The daemon will continue
|
|
36
|
+
* running after the parent pi process exits.
|
|
37
|
+
*
|
|
38
|
+
* The daemon entry point is resolved relative to this module. When running
|
|
39
|
+
* from source (tsx), the entry is src/daemon/index.ts. When running compiled,
|
|
40
|
+
* it's dist/daemon/index.js.
|
|
41
|
+
*
|
|
42
|
+
* Returns the child process handle (caller should call child.unref()).
|
|
43
|
+
*/
|
|
44
|
+
export const spawnDaemon = (): ChildProcess | null => {
|
|
45
|
+
// Resolve the daemon entry point and tsx binary.
|
|
46
|
+
const daemonScript = join(moduleDir, "index.ts");
|
|
47
|
+
const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
|
|
48
|
+
|
|
49
|
+
// Use tsx to run the daemon TypeScript source directly.
|
|
50
|
+
// The spawed process inherits the parent's env so module resolution works.
|
|
51
|
+
// Fall back to `npx tsx` if the local binary isn't available.
|
|
52
|
+
const cmd = existsSync(tsxBin) ? tsxBin : "npx";
|
|
53
|
+
const args = existsSync(tsxBin) ? [daemonScript] : ["tsx", daemonScript];
|
|
54
|
+
|
|
55
|
+
const child = spawn(cmd, args, {
|
|
56
|
+
detached: true,
|
|
57
|
+
stdio: "ignore",
|
|
58
|
+
env: { ...process.env },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
child.unref();
|
|
62
|
+
return child;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs)
|
|
67
|
+
* for the socket file to appear.
|
|
68
|
+
*
|
|
69
|
+
* Returns true if the daemon is running (or was successfully spawned).
|
|
70
|
+
*/
|
|
71
|
+
export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => {
|
|
72
|
+
if (await isDaemonRunning()) return true;
|
|
73
|
+
|
|
74
|
+
const child = spawnDaemon();
|
|
75
|
+
if (!child) return false;
|
|
76
|
+
|
|
77
|
+
// Poll for the socket file to appear
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
if (await isDaemonRunning()) return true;
|
|
81
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return false;
|
|
85
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { Markdown, Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import { type Result, ok } from "../util/result";
|
|
5
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
6
|
+
import { applyTruncation } from "../util/truncate";
|
|
7
|
+
import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer";
|
|
8
|
+
|
|
9
|
+
const ConsoleArgs = Type.Object({
|
|
10
|
+
levels: Type.Optional(
|
|
11
|
+
Type.Array(
|
|
12
|
+
Type.Union(
|
|
13
|
+
[
|
|
14
|
+
Type.Literal("log"),
|
|
15
|
+
Type.Literal("info"),
|
|
16
|
+
Type.Literal("warn"),
|
|
17
|
+
Type.Literal("error"),
|
|
18
|
+
Type.Literal("debug"),
|
|
19
|
+
],
|
|
20
|
+
),
|
|
21
|
+
{ description: 'Filter to these levels only, e.g. ["error","warn"]. Default: all levels.' },
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
textPattern: Type.Optional(
|
|
25
|
+
Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /TypeError/) for regex." }),
|
|
26
|
+
),
|
|
27
|
+
sinceSeq: Type.Optional(
|
|
28
|
+
Type.Integer({ minimum: 0, description: "Only records with seq strictly greater than this. Use the previous call's nextCursor to see only what's new." }),
|
|
29
|
+
),
|
|
30
|
+
sinceMs: Type.Optional(
|
|
31
|
+
Type.Integer({ minimum: 0, description: "Only records from the last N ms." }),
|
|
32
|
+
),
|
|
33
|
+
limit: Type.Optional(
|
|
34
|
+
Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const levelTag = (lvl: ConsoleLevel): string => {
|
|
39
|
+
switch (lvl) {
|
|
40
|
+
case "error": return "[error]";
|
|
41
|
+
case "warn": return "[warn] ";
|
|
42
|
+
case "info": return "[info] ";
|
|
43
|
+
case "debug": return "[debug]";
|
|
44
|
+
default: return "[log] ";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const formatLocation = (r: ConsoleRecord): string => {
|
|
49
|
+
if (r.url === undefined || r.url === "") return "";
|
|
50
|
+
const short = r.url.length > 60 ? "…" + r.url.slice(-59) : r.url;
|
|
51
|
+
return r.lineNumber !== undefined ? `${short}:${r.lineNumber}` : short;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const renderConsoleMarkdown = (
|
|
55
|
+
records: ReadonlyArray<ConsoleRecord>,
|
|
56
|
+
opts: { total: number; bufferOverflowed: boolean; expanded: boolean; nextCursor: number | undefined },
|
|
57
|
+
): string => {
|
|
58
|
+
if (records.length === 0) {
|
|
59
|
+
if (opts.bufferOverflowed) return "_No matching console records. (Buffer overflowed since last drain — some events were dropped.)_";
|
|
60
|
+
return "_No matching console records captured yet._";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const counts = records.reduce<Record<ConsoleLevel, number>>(
|
|
64
|
+
(acc, r) => { acc[r.level]++; return acc; },
|
|
65
|
+
{ log: 0, info: 0, warn: 0, error: 0, debug: 0 },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const summaryParts: string[] = [];
|
|
69
|
+
if (counts.error > 0) summaryParts.push(`${counts.error} error${counts.error === 1 ? "" : "s"}`);
|
|
70
|
+
if (counts.warn > 0) summaryParts.push(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`);
|
|
71
|
+
if (counts.info > 0) summaryParts.push(`${counts.info} info`);
|
|
72
|
+
if (counts.log > 0) summaryParts.push(`${counts.log} log${counts.log === 1 ? "" : "s"}`);
|
|
73
|
+
if (counts.debug > 0) summaryParts.push(`${counts.debug} debug`);
|
|
74
|
+
|
|
75
|
+
const headerLines: string[] = [`**${summaryParts.join(" · ")}**`];
|
|
76
|
+
if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older entries dropped");
|
|
77
|
+
if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
|
|
78
|
+
|
|
79
|
+
const renderRow = (r: ConsoleRecord, compact: boolean): string => {
|
|
80
|
+
const tag = levelTag(r.level);
|
|
81
|
+
const loc = formatLocation(r);
|
|
82
|
+
const maxText = compact ? 80 : 200;
|
|
83
|
+
const text = r.text.length > maxText ? r.text.slice(0, maxText - 1) + "…" : r.text;
|
|
84
|
+
const locSuffix = loc !== "" ? ` _at ${loc}_` : "";
|
|
85
|
+
const main = `\`${tag}\` ${text}${locSuffix}`;
|
|
86
|
+
if (!compact && r.stackTrace !== undefined) return `${main}\n\`\`\`\n${r.stackTrace}\n\`\`\``;
|
|
87
|
+
return main;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const visible = !opts.expanded && records.length > 5 ? records.slice(-5) : records;
|
|
91
|
+
const rows = visible.map((r) => renderRow(r, !opts.expanded)).join(opts.expanded ? "\n\n" : "\n");
|
|
92
|
+
|
|
93
|
+
const more = !opts.expanded && records.length > 5 ? `\n … ${records.length - 5} earlier (expand to see all)` : "";
|
|
94
|
+
const cursor = opts.nextCursor !== undefined ? `\n\n_nextCursor=${opts.nextCursor}_` : "";
|
|
95
|
+
|
|
96
|
+
return `${headerLines.join("\n")}\n\n${rows}${more}${cursor}`;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type ConsoleDetails = {
|
|
100
|
+
total: number;
|
|
101
|
+
returned: number;
|
|
102
|
+
bufferOverflowed: boolean;
|
|
103
|
+
truncated: boolean;
|
|
104
|
+
fullOutputPath?: string;
|
|
105
|
+
records: ReadonlyArray<ConsoleRecord>;
|
|
106
|
+
nextCursor?: number;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const consoleTool = defineBrowserTool({
|
|
110
|
+
name: "browser_console",
|
|
111
|
+
label: "Browser Console",
|
|
112
|
+
description:
|
|
113
|
+
"Read JS errors and console messages from the active tab. Diagnostic — use when an action looks broken or silent.",
|
|
114
|
+
promptSnippet: "Read JS errors / console output on the current tab",
|
|
115
|
+
promptGuidelines: [
|
|
116
|
+
"Use after a click, submit, or navigate produced no visible change — errors often explain it.",
|
|
117
|
+
"Pass `sinceSeq` from the previous call's `nextCursor` to see only new messages after an action.",
|
|
118
|
+
"Buffer is reset on tab switch (capacity 500 records). bufferOverflowed:true means older entries were dropped.",
|
|
119
|
+
"Don't use as a default observation tool — prefer browser_snapshot for page structure.",
|
|
120
|
+
],
|
|
121
|
+
parameters: ConsoleArgs,
|
|
122
|
+
|
|
123
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
124
|
+
const session = client.session();
|
|
125
|
+
const drained = session.drainConsoleBuffer({
|
|
126
|
+
...(args.levels !== undefined ? { levels: args.levels } : {}),
|
|
127
|
+
...(args.textPattern !== undefined ? { textPattern: args.textPattern } : {}),
|
|
128
|
+
...(args.sinceSeq !== undefined ? { sinceSeq: args.sinceSeq } : {}),
|
|
129
|
+
...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
|
|
130
|
+
limit: args.limit ?? 50,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const last = drained.records.at(-1);
|
|
134
|
+
const nextCursor = last?.seq;
|
|
135
|
+
|
|
136
|
+
const text = renderConsoleMarkdown(drained.records, {
|
|
137
|
+
total: drained.total,
|
|
138
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
139
|
+
expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans
|
|
140
|
+
nextCursor,
|
|
141
|
+
});
|
|
142
|
+
const trunc = await applyTruncation(text, "console");
|
|
143
|
+
|
|
144
|
+
const details: ConsoleDetails = {
|
|
145
|
+
total: drained.total,
|
|
146
|
+
returned: drained.records.length,
|
|
147
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
148
|
+
truncated: trunc.wasTruncated,
|
|
149
|
+
records: drained.records,
|
|
150
|
+
...(nextCursor !== undefined ? { nextCursor } : {}),
|
|
151
|
+
};
|
|
152
|
+
if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
|
|
153
|
+
|
|
154
|
+
return ok({ text: trunc.text, details });
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
renderResult(result, expanded, theme) {
|
|
158
|
+
const details = result.details as ConsoleDetails | undefined;
|
|
159
|
+
if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0);
|
|
160
|
+
|
|
161
|
+
const md = renderConsoleMarkdown(details.records, {
|
|
162
|
+
total: details.total,
|
|
163
|
+
bufferOverflowed: details.bufferOverflowed,
|
|
164
|
+
expanded,
|
|
165
|
+
nextCursor: details.nextCursor,
|
|
166
|
+
});
|
|
167
|
+
const tail = expanded
|
|
168
|
+
? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
|
|
169
|
+
: `\n\n${keyHint("app.tools.expand", "to expand")}`;
|
|
170
|
+
|
|
171
|
+
return new Markdown(md + tail, 0, 0, getMarkdownTheme());
|
|
172
|
+
},
|
|
173
|
+
});
|
package/src/domains/navigate.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Text } from "@mariozechner/pi-tui";
|
|
|
3
3
|
import { type Result, err, ok } from "../util/result";
|
|
4
4
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
5
5
|
import { applyTruncation } from "../util/truncate";
|
|
6
|
+
import { safeJs } from "../util/js-template";
|
|
6
7
|
|
|
7
8
|
const NavigateArgs = Type.Object({
|
|
8
9
|
url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
|
|
@@ -82,11 +83,18 @@ export const openUrlsTool = defineBrowserTool({
|
|
|
82
83
|
renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
|
|
83
84
|
async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
|
|
84
85
|
const total = args.urls.length;
|
|
85
|
-
// Phase 1: create tabs in parallel.
|
|
86
|
+
// Phase 1: create tabs in parallel. Own them so they appear in
|
|
87
|
+
// browser_list_tabs scope:'owned' and group in the harness window.
|
|
88
|
+
const hw = client.ownership().harnessWindow();
|
|
86
89
|
const created = await Promise.all(args.urls.map(async (url): Promise<TabResult> => {
|
|
87
|
-
const
|
|
90
|
+
const params: Record<string, unknown> = { url: "about:blank" };
|
|
91
|
+
if (hw) params["openerId"] = hw;
|
|
92
|
+
else params["newWindow"] = true;
|
|
93
|
+
const r = await client.session().callBrowser("Target.createTarget", params);
|
|
88
94
|
if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
|
|
89
|
-
const c = r.data as { targetId: string };
|
|
95
|
+
const c = r.data as { targetId: string };
|
|
96
|
+
if (params["newWindow"]) client.ownership().setHarnessWindow(c.targetId);
|
|
97
|
+
client.ownership().add(c.targetId);
|
|
90
98
|
return { url, targetId: c.targetId, ok: true };
|
|
91
99
|
}));
|
|
92
100
|
// Phase 2: attach + navigate each created tab in parallel.
|
|
@@ -101,6 +109,12 @@ export const openUrlsTool = defineBrowserTool({
|
|
|
101
109
|
if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message };
|
|
102
110
|
const nav = await client.session().callOnTarget("Page.navigate", { url: tab.url }, sid);
|
|
103
111
|
if (!nav.success) return { ...tab, ok: false, error: nav.error.message };
|
|
112
|
+
// Mark the tab with a green circle so the user can see which
|
|
113
|
+
// tabs the agent opened. Best-effort; don't fail on CSP blocks.
|
|
114
|
+
client.evaluateJs(
|
|
115
|
+
safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
|
|
116
|
+
sid
|
|
117
|
+
).catch(() => {});
|
|
104
118
|
return tab;
|
|
105
119
|
} finally {
|
|
106
120
|
completed++;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser_setup tool — agent-callable browser initialization.
|
|
3
|
+
*
|
|
4
|
+
* The agent can call this directly when browser tools fail with
|
|
5
|
+
* "not initialized" instead of asking the user. Idempotent: safe
|
|
6
|
+
* to call even when already connected.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
11
|
+
import type { Result } from "../util/result";
|
|
12
|
+
import { err, ok } from "../util/result";
|
|
13
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
14
|
+
import { performSetup } from "../setup";
|
|
15
|
+
|
|
16
|
+
const SetupArgs = Type.Object({});
|
|
17
|
+
|
|
18
|
+
export const setupTool = defineBrowserTool({
|
|
19
|
+
name: "browser_setup",
|
|
20
|
+
label: "Browser Setup",
|
|
21
|
+
description:
|
|
22
|
+
"Initialize browser control. Spawns the daemon, connects to Chrome, opens a test tab. " +
|
|
23
|
+
"Call when browser tools fail with 'Browser harness not initialized'. Idempotent.",
|
|
24
|
+
promptSnippet: "Initialize browser connection",
|
|
25
|
+
promptGuidelines: [
|
|
26
|
+
"Call browser_setup when you get a 'Browser harness not initialized' error from any browser tool.",
|
|
27
|
+
"This tool is idempotent — calling it when already connected is harmless.",
|
|
28
|
+
"After browser_setup succeeds, retry the browser tool that failed.",
|
|
29
|
+
],
|
|
30
|
+
parameters: SetupArgs,
|
|
31
|
+
// ensureAlive:false — this tool IS the setup; it must bypass the socket guard
|
|
32
|
+
ensureAlive: false,
|
|
33
|
+
renderCall: () => new Text("🔧 Initializing browser...", 0, 0),
|
|
34
|
+
async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
35
|
+
const result = await performSetup(client);
|
|
36
|
+
if (result.success) {
|
|
37
|
+
return ok({ text: result.data });
|
|
38
|
+
}
|
|
39
|
+
return err({ kind: "internal", message: result.error });
|
|
40
|
+
},
|
|
41
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { registerSetupCommand } from "./setup";
|
|
|
27
27
|
import { type BrowserState, defaultState, persistState, restoreState } from "./state";
|
|
28
28
|
import { registerAllTools } from "./registry";
|
|
29
29
|
import { cleanupTempDirs } from "./util/truncate";
|
|
30
|
+
import { createDaemonTransport } from "./cdp/daemon-transport";
|
|
30
31
|
|
|
31
32
|
export default function browserHarnessExtension(pi: ExtensionAPI): void {
|
|
32
33
|
const flagNs = pi.getFlag("browser-namespace") as string | undefined;
|
|
@@ -98,25 +99,43 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
|
|
|
98
99
|
|
|
99
100
|
pi.on("session_start", async (_event, ctx) => {
|
|
100
101
|
state = restoreState(ctx, state.namespace);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
102
|
+
|
|
103
|
+
// Create the client lazily on first session. The daemon transport
|
|
104
|
+
// is created but NOT connected — that only happens when the user
|
|
105
|
+
// runs /browser-setup or a browser tool call finds an existing socket.
|
|
106
|
+
// This means zero Chrome prompts for non-browser pi sessions.
|
|
107
|
+
if (!client) {
|
|
108
|
+
const transport = createDaemonTransport(state.namespace);
|
|
109
|
+
|
|
110
|
+
const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
|
|
111
|
+
if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds;
|
|
112
|
+
if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId;
|
|
113
|
+
|
|
114
|
+
client = createBrowserClient({
|
|
115
|
+
namespace: state.namespace,
|
|
116
|
+
transport,
|
|
117
|
+
...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}),
|
|
118
|
+
onOwnershipChange: (snap) => {
|
|
119
|
+
state = {
|
|
120
|
+
...state,
|
|
121
|
+
ownedTargetIds: snap.ownedTargetIds,
|
|
122
|
+
...(snap.harnessWindowTargetId !== undefined
|
|
123
|
+
? { harnessWindowTargetId: snap.harnessWindowTargetId }
|
|
124
|
+
: {}),
|
|
125
|
+
};
|
|
126
|
+
// Ownership changes can fire from CDP events at any time,
|
|
127
|
+
// including after session replacement when pi's ctx is stale.
|
|
128
|
+
// Persistence is best-effort — failure must not crash the event consumer.
|
|
129
|
+
// ponytail: try/catch is the simplest guard against stale-ctx.
|
|
130
|
+
try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ }
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Do NOT call client.start() here — browser control is on-demand.
|
|
136
|
+
// The first browser tool call or /browser-setup triggers the actual
|
|
137
|
+
// daemon socket connection and Chrome prompt.
|
|
138
|
+
|
|
120
139
|
if (!toolsRegistered) {
|
|
121
140
|
registerAllTools(pi, client);
|
|
122
141
|
toolsRegistered = true;
|
|
@@ -132,15 +151,17 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
|
|
|
132
151
|
persistState(pi, state);
|
|
133
152
|
if (client) {
|
|
134
153
|
try {
|
|
135
|
-
|
|
154
|
+
// Detach from the page target (removes the "Chrome is being controlled"
|
|
155
|
+
// banner) but keep the transport alive for the next session.
|
|
156
|
+
await client.detach();
|
|
136
157
|
} catch (e) {
|
|
137
|
-
|
|
138
|
-
// bug worth surfacing for debugging.
|
|
139
|
-
console.warn("[pi-browser-harness] client.stop() failed during shutdown:", e);
|
|
158
|
+
console.warn("[pi-browser-harness] client.detach() failed during shutdown:", e);
|
|
140
159
|
}
|
|
141
|
-
|
|
160
|
+
// ponytail: keep the transport alive across sessions.
|
|
161
|
+
// Do NOT call client.stop() or null out client — the daemon connection
|
|
162
|
+
// persists and eliminates the per-session "Allow Remote Debugging" prompt.
|
|
163
|
+
// Only Chrome restart or daemon death triggers a new prompt.
|
|
142
164
|
}
|
|
143
|
-
toolsRegistered = false;
|
|
144
165
|
await cleanupTempDirs();
|
|
145
166
|
});
|
|
146
167
|
|