pi-browser-harness 0.1.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/src/index.ts ADDED
@@ -0,0 +1,223 @@
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
+ * Spawns the browser-harness daemon, registers browser_* tools,
6
+ * and injects browser control guidance into the system prompt.
7
+ *
8
+ * Install:
9
+ * pi install npm:pi-browser-harness
10
+ * # or copy to .pi/extensions/pi-browser-harness/
11
+ *
12
+ * Commands:
13
+ * /browser-setup — guided setup wizard
14
+ * /browser-status — show daemon status and current page
15
+ * /browser-reload-daemon — restart the daemon
16
+ *
17
+ * Flags:
18
+ * --browser-namespace <name> — override BU_NAME (default: auto)
19
+ * --browser-debug-clicks — enable debug click overlay
20
+ */
21
+
22
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
23
+ import { BrowserDaemon } from "./daemon";
24
+ import { getBrowserSystemPrompt } from "./prompt";
25
+ import { registerRenderers } from "./renderers";
26
+ import { registerSetupCommand } from "./setup";
27
+ import { type BrowserState, defaultState, persistState, restoreState } from "./state";
28
+ import { cleanupTempDirs, registerTools } from "./tools";
29
+
30
+ export default function browserHarnessExtension(pi: ExtensionAPI) {
31
+ // ── Resolve namespace ──────────────────────────────────────────────────────
32
+ const flagNs = pi.getFlag("browser-namespace") as string | undefined;
33
+ const namespace = flagNs || `pi-${Math.random().toString(36).slice(2, 10)}`;
34
+
35
+ // ── State ──────────────────────────────────────────────────────────────────
36
+ let state: BrowserState = defaultState(namespace);
37
+ let daemon: BrowserDaemon | null = null;
38
+
39
+ // ── Tool initialization flag ───────────────────────────────────────────────
40
+ let toolsRegistered = false;
41
+
42
+ // ═══════════════════════════════════════════════════════════════════════════
43
+ // Flags
44
+ // ═══════════════════════════════════════════════════════════════════════════
45
+ pi.registerFlag("browser-namespace", {
46
+ description: "Browser daemon namespace (BU_NAME). Default: auto-generated",
47
+ type: "string",
48
+ });
49
+ pi.registerFlag("browser-debug-clicks", {
50
+ description: "Enable debug click overlay (saves annotated screenshots to /tmp)",
51
+ type: "boolean",
52
+ default: false,
53
+ });
54
+
55
+ // ═══════════════════════════════════════════════════════════════════════════
56
+ // Commands
57
+ // ═══════════════════════════════════════════════════════════════════════════
58
+ pi.registerCommand("browser-status", {
59
+ description: "Show browser connection status and current page",
60
+ handler: async (_args, ctx) => {
61
+ if (!daemon) {
62
+ ctx.ui.notify("Browser daemon not started. Run /browser-setup first.", "warning");
63
+ return;
64
+ }
65
+
66
+ const status = daemon.getStatus();
67
+ const lines = [
68
+ `Browser: ${status.alive ? "🟢 Connected" : "🔴 Disconnected"}`,
69
+ `Session: ${status.sessionId || "none"}`,
70
+ ];
71
+
72
+ if (status.remoteBrowserId) {
73
+ lines.push(`Browser ID: ${status.remoteBrowserId}`);
74
+ }
75
+
76
+ // Try to get current page info
77
+ if (status.alive) {
78
+ try {
79
+ const info = await daemon.getPageInfo();
80
+ if ("dialog" in info) {
81
+ lines.push(`\n⚠️ Dialog open: ${info.dialog.type} — "${info.dialog.message}"`);
82
+ } else {
83
+ lines.push(`\nCurrent Page:`);
84
+ lines.push(` URL: ${info.url}`);
85
+ lines.push(` Title: ${info.title}`);
86
+ lines.push(` Viewport: ${info.width}x${info.height}`);
87
+ }
88
+ } catch {
89
+ lines.push("\n(Could not read page info)");
90
+ }
91
+ }
92
+
93
+ ctx.ui.notify(lines.join("\n"), "info");
94
+ },
95
+ });
96
+
97
+ pi.registerCommand("browser-reload-daemon", {
98
+ description: "Restart the browser daemon",
99
+ handler: async (_args, ctx) => {
100
+ if (!daemon) {
101
+ ctx.ui.notify("Browser daemon not started.", "warning");
102
+ return;
103
+ }
104
+
105
+ ctx.ui.notify("Restarting browser daemon...", "info");
106
+ try {
107
+ await daemon.stop();
108
+ await daemon.start();
109
+ ctx.ui.notify("Browser daemon restarted ✓", "info");
110
+ } catch (err) {
111
+ ctx.ui.notify(
112
+ `Restart failed: ${err instanceof Error ? err.message : String(err)}`,
113
+ "error",
114
+ );
115
+ }
116
+ },
117
+ });
118
+
119
+ // ═══════════════════════════════════════════════════════════════════════════
120
+ // Session lifecycle
121
+ // ═══════════════════════════════════════════════════════════════════════════
122
+ pi.on("session_start", async (_event, ctx) => {
123
+ // Restore persisted state (preserving the current namespace from flag)
124
+ state = restoreState(ctx, state.namespace);
125
+
126
+ // Create daemon instance
127
+ daemon = new BrowserDaemon(state.namespace);
128
+
129
+ // Attempt to start daemon (will fail gracefully if Chrome not connected)
130
+ try {
131
+ await daemon.start();
132
+ } catch {
133
+ // Don't block — user can run /browser-setup later
134
+ }
135
+
136
+ // Register tools (once)
137
+ if (!toolsRegistered && daemon) {
138
+ registerTools(pi, daemon);
139
+ toolsRegistered = true;
140
+ }
141
+
142
+ // Register renderers
143
+ registerRenderers(pi);
144
+
145
+ // Setup command (always available)
146
+ if (daemon) {
147
+ registerSetupCommand(pi, daemon);
148
+ }
149
+
150
+ // Update status
151
+ if (daemon.getStatus().alive) {
152
+ ctx.ui.setStatus("browser", "🟢 Browser connected");
153
+ } else {
154
+ ctx.ui.setStatus("browser", "🔴 Browser — run /browser-setup");
155
+ }
156
+ });
157
+
158
+ pi.on("session_shutdown", async (_event) => {
159
+ // Persist current state
160
+ if (state) {
161
+ persistState(pi, state);
162
+ }
163
+
164
+ // Stop daemon
165
+ if (daemon) {
166
+ try {
167
+ await daemon.stop();
168
+ } catch {
169
+ // best-effort
170
+ }
171
+ daemon = null;
172
+ }
173
+
174
+ toolsRegistered = false;
175
+
176
+ // Clean up temp files from truncated tool outputs
177
+ await cleanupTempDirs();
178
+ });
179
+
180
+ // Restore state on tree navigation
181
+ pi.on("session_tree", async (_event, ctx) => {
182
+ state = restoreState(ctx, daemon?.namespace);
183
+ if (daemon) {
184
+ persistState(pi, state);
185
+ }
186
+ });
187
+
188
+ // ═══════════════════════════════════════════════════════════════════════════
189
+ // System prompt injection
190
+ // ═══════════════════════════════════════════════════════════════════════════
191
+ pi.on("before_agent_start", async (event) => {
192
+ if (!daemon || !daemon.getStatus().alive) {
193
+ // Browser not connected — inject minimal note
194
+ return {
195
+ systemPrompt:
196
+ event.systemPrompt +
197
+ `\n\n## Browser Control\n\nBrowser tools (browser_*) are available but the browser daemon is not connected. ` +
198
+ `Run /browser-setup to connect to Chrome, or /browser-status to check.`,
199
+ };
200
+ }
201
+
202
+ return {
203
+ systemPrompt: event.systemPrompt + getBrowserSystemPrompt(),
204
+ };
205
+ });
206
+
207
+ // ═══════════════════════════════════════════════════════════════════════════
208
+ // Tool result hook: track tab history
209
+ // ═══════════════════════════════════════════════════════════════════════════
210
+ pi.on("tool_result", async (event) => {
211
+ if (event.toolName === "browser_new_tab" || event.toolName === "browser_navigate") {
212
+ // Track new tabs in history
213
+ const details = event.details as { targetId?: string } | undefined;
214
+ if (details?.targetId) {
215
+ state = {
216
+ ...state,
217
+ tabHistory: [details.targetId, ...state.tabHistory.filter((id) => id !== details.targetId)].slice(0, 20),
218
+ };
219
+ persistState(pi, state);
220
+ }
221
+ }
222
+ });
223
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * System prompt guidance for browser usage.
3
+ *
4
+ * Injects a compact browser control reference into the pi system prompt.
5
+ * Per-tool guidelines are injected via promptGuidelines on each tool registration.
6
+ * This prompt provides only the cross-cutting patterns and tool reference table.
7
+ */
8
+
9
+ export function getBrowserSystemPrompt(): string {
10
+ return `
11
+ ## Browser Control
12
+
13
+ You have full control of a real Chrome browser via \`browser_*\` tools.
14
+ The browser connects to the user's running Chrome (not a headless instance).
15
+
16
+ **These tools coexist with all other standard pi tools** — read, bash, edit,
17
+ write, and any other built-in tools are still available. Use browser_* tools
18
+ to interact with pages visually, and other tools alongside them.
19
+
20
+ ### Browser-Specific Tools
21
+
22
+ | Tool | Purpose |
23
+ |------|---------|
24
+ | browser_screenshot | Capture a PNG screenshot of the current page |
25
+ | browser_click | Click at viewport coordinates |
26
+ | browser_type | Type text into the focused element |
27
+ | browser_press_key | Press a keyboard key (Enter, Tab, Escape, arrows, etc.) |
28
+ | browser_scroll | Scroll the page at coordinates |
29
+ | browser_navigate | Navigate to a URL |
30
+ | browser_new_tab | Open a new tab, optionally navigate |
31
+ | browser_open_urls | Open multiple URLs in new tabs (parallel) |
32
+ | browser_go_back / browser_go_forward / browser_reload | History navigation |
33
+ | browser_page_info | Get page URL, title, viewport, scroll position, or dialog info |
34
+ | browser_list_tabs | List all open browser tabs |
35
+ | browser_current_tab | Get current tab info |
36
+ | browser_switch_tab | Switch to a different tab by targetId |
37
+ | browser_execute_js | Execute JavaScript and return the result |
38
+ | browser_http_get | Direct HTTP GET (outside browser, for APIs) |
39
+ | browser_wait | Wait N seconds |
40
+ | browser_wait_for_load | Wait for document.readyState === 'complete' |
41
+ | browser_handle_dialog | Accept or dismiss a JS dialog |
42
+ | browser_run_script | Execute a temporary script file with daemon access (write script to disk, then run) |
43
+
44
+ ### Temporary Scripts
45
+
46
+ When the built-in tools aren't enough for a multi-step workflow, write a temporary
47
+ script to disk and execute it with browser_run_script. The script runs in the
48
+ harness process with direct access to the browser daemon and Node.js APIs.
49
+
50
+ \`\`\`
51
+ write("/tmp/scrape-pages.js", \`
52
+ const results = [];
53
+ for (const url of params.urls) {
54
+ await daemon.cdp("Page.navigate", { url });
55
+ await new Promise(r => setTimeout(r, 2000));
56
+ const data = await daemon.evaluateJS("document.title");
57
+ results.push({ url, title: data });
58
+ }
59
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
60
+ \`)
61
+ browser_run_script("/tmp/scrape-pages.js", { urls: [...] })
62
+ \`\`\`
63
+
64
+ Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON, Buffer, setTimeout, clearTimeout.
65
+
66
+ ### Common Patterns
67
+
68
+ **Navigation:**
69
+ \`\`\`
70
+ browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot()
71
+ \`\`\`
72
+
73
+ **Form filling:**
74
+ \`\`\`
75
+ browser_screenshot() → find input coordinates → browser_click(x, y)
76
+ → browser_type("text") → browser_press_key("Tab") → browser_screenshot()
77
+ \`\`\`
78
+
79
+ **Data extraction:**
80
+ \`\`\`
81
+ browser_execute_js("document.querySelector('.price').innerText")
82
+ // or for APIs:
83
+ browser_http_get("https://api.example.com/data")
84
+ \`\`\`
85
+
86
+ **Scrolling:**
87
+ \`\`\`
88
+ browser_screenshot() → browser_scroll({ deltaY: -500 }) → browser_screenshot()
89
+ \`\`\`
90
+
91
+ **Research Workflow (search + browser):**
92
+ \`\`\`
93
+ browser_navigate("https://google.com/search?q=...") → search engine in a tab
94
+ browser_open_urls(urls: ["url1", "url2", ...]) → open result pages in parallel tabs
95
+ browser_list_tabs() → see all open tabs with targetIds
96
+ browser_switch_tab(targetId: "...") → switch to a tab
97
+ browser_screenshot() → visually inspect the page
98
+ browser_execute_js("document.querySelector('.main').innerText") → extract content
99
+ \`\`\`
100
+
101
+ **Temporary Scripts (extending the harness):**
102
+ \`\`\`
103
+ write("/tmp/extract.js", "...script with daemon access...") → browser_run_script("/tmp/extract.js")
104
+ \`\`\`
105
+ `;
106
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Protocol types for pi-browser-harness.
3
+ *
4
+ * Defines the shape of CDP communication and browser state.
5
+ * No socket transport — the daemon talks CDP directly over WebSocket.
6
+ */
7
+
8
+ // ── CDP Event ────────────────────────────────────────────────────────────────
9
+
10
+ export interface CDPEvent {
11
+ method: string;
12
+ params: unknown;
13
+ session_id?: string;
14
+ }
15
+
16
+ // ── Dialog ───────────────────────────────────────────────────────────────────
17
+
18
+ export interface DialogInfo {
19
+ type: "alert" | "confirm" | "prompt" | "beforeunload";
20
+ message: string;
21
+ defaultPrompt?: string;
22
+ }
23
+
24
+ // ── Tab ──────────────────────────────────────────────────────────────────────
25
+
26
+ export interface TabInfo {
27
+ targetId: string;
28
+ title: string;
29
+ url: string;
30
+ }
31
+
32
+ // ── Page State ───────────────────────────────────────────────────────────────
33
+
34
+ export interface PageInfo {
35
+ url: string;
36
+ title: string;
37
+ width: number;
38
+ height: number;
39
+ scrollX: number;
40
+ scrollY: number;
41
+ pageWidth: number;
42
+ pageHeight: number;
43
+ }
44
+
45
+ export type PageInfoResult = PageInfo | { dialog: DialogInfo };
46
+
47
+ // ── Daemon Status ────────────────────────────────────────────────────────────
48
+
49
+ export interface DaemonStatus {
50
+ alive: boolean;
51
+ sessionId: string | null;
52
+ pid: number | null; // deprecated — always null (no subprocess)
53
+ namespace: string;
54
+ socketPath: string; // deprecated — always "" (no socket)
55
+ remoteBrowserId?: string;
56
+ }
57
+
58
+ // ── Remote Config ────────────────────────────────────────────────────────────
59
+
60
+ export interface RemoteConfig {
61
+ cdpUrl: string;
62
+ browserId: string;
63
+ }
64
+
65
+ // ── Internal URL prefixes to filter ──────────────────────────────────────────
66
+
67
+ const INTERNAL_PREFIXES = [
68
+ "chrome://",
69
+ "chrome-untrusted://",
70
+ "devtools://",
71
+ "chrome-extension://",
72
+ "about:",
73
+ ];
74
+
75
+ export function isInternalUrl(url: string): boolean {
76
+ return INTERNAL_PREFIXES.some((p) => url.startsWith(p));
77
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * TUI renderers for pi-browser-harness.
3
+ *
4
+ * Registers custom message renderers and tool renderCall/renderResult
5
+ * for browser tools.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { Text } from "@mariozechner/pi-tui";
10
+
11
+ export function registerRenderers(pi: ExtensionAPI): void {
12
+ // Register a message renderer for screenshot notifications
13
+ pi.registerMessageRenderer("browser-screenshot", (message, _options, theme) => {
14
+ const path = (message as { details?: { path?: string } }).details?.path || "unknown";
15
+ return new Text(
16
+ theme.fg("accent", `📸 Screenshot: ${path}`),
17
+ 0,
18
+ 0,
19
+ );
20
+ });
21
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Setup command — verifies Chrome is running with remote debugging
3
+ * and connects the pi agent to it.
4
+ */
5
+
6
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
7
+ import { execSync } from "node:child_process";
8
+ import type { BrowserDaemon } from "./daemon";
9
+
10
+ export function registerSetupCommand(pi: ExtensionAPI, daemon: BrowserDaemon): void {
11
+ pi.registerCommand("browser-setup", {
12
+ description: "Connect pi to your Chrome browser",
13
+ handler: async (_args, ctx) => {
14
+ await runSetup(ctx, daemon);
15
+ },
16
+ });
17
+ }
18
+
19
+ async function runSetup(ctx: ExtensionContext, daemon: BrowserDaemon): Promise<void> {
20
+ ctx.ui.notify("browser setup: checking Chrome...", "info");
21
+
22
+ // Step 1: Check Chrome is running
23
+ const chromeRunning = checkChromeRunning();
24
+ if (!chromeRunning) {
25
+ ctx.ui.notify(
26
+ "Chrome/Chromium/Edge not detected. Please start your browser and retry /browser-setup.",
27
+ "error",
28
+ );
29
+ return;
30
+ }
31
+ ctx.ui.notify("Chrome is running ✓", "info");
32
+
33
+ // Step 2: Try to connect to Chrome DevTools
34
+ ctx.ui.notify("Connecting to Chrome DevTools...", "info");
35
+ try {
36
+ await daemon.start();
37
+ ctx.ui.notify("Connected to Chrome ✓", "info");
38
+ } catch (err) {
39
+ const msg = err instanceof Error ? err.message : String(err);
40
+ const lower = msg.toLowerCase();
41
+
42
+ if (
43
+ lower.includes("devtoolsactiveport") ||
44
+ lower.includes("remote debugging") ||
45
+ lower.includes("econnrefused") ||
46
+ lower.includes("cannot reach chrome devtools")
47
+ ) {
48
+ ctx.ui.notify(
49
+ "Chrome remote debugging needs to be enabled.\n\n" +
50
+ "Open chrome://inspect/#remote-debugging in your browser, tick the\n" +
51
+ "\"Discover network targets\" / Allow checkbox, then run /browser-setup again.\n\n" +
52
+ "Or set BU_CDP_WS to a remote browser WebSocket URL.",
53
+ "warning",
54
+ );
55
+ return;
56
+ }
57
+
58
+ ctx.ui.notify(`Connection failed: ${msg}`, "error");
59
+ return;
60
+ }
61
+
62
+ // Step 3: Verify with test navigation
63
+ ctx.ui.notify("Testing browser control...", "info");
64
+ try {
65
+ await daemon.newTab("https://github.com");
66
+ const info = await daemon.getPageInfo();
67
+ if ("dialog" in info) {
68
+ await daemon.cdp("Page.handleJavaScriptDialog", { accept: true });
69
+ }
70
+ ctx.ui.notify(
71
+ `Browser control verified ✓\nNavigated to: ${"url" in info ? info.url : "github.com"}`,
72
+ "info",
73
+ );
74
+ } catch (err) {
75
+ ctx.ui.notify(
76
+ `Browser connected but test navigation failed: ${err instanceof Error ? err.message : String(err)}`,
77
+ "warning",
78
+ );
79
+ }
80
+ }
81
+
82
+ function checkChromeRunning(): boolean {
83
+ try {
84
+ if (process.platform === "darwin" || process.platform === "linux") {
85
+ const out = execSync("ps -A -o comm=", { timeout: 5000 }).toString().toLowerCase();
86
+ return ["google chrome", "chrome", "chromium", "microsoft edge", "msedge"].some(
87
+ (n) => out.includes(n),
88
+ );
89
+ } else if (process.platform === "win32") {
90
+ const out = execSync("tasklist", { timeout: 5000 }).toString().toLowerCase();
91
+ return ["chrome.exe", "msedge.exe"].some((n) => out.includes(n));
92
+ }
93
+ } catch {
94
+ // best-effort
95
+ }
96
+ return false;
97
+ }
package/src/state.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Session persistence for pi-browser-harness.
3
+ *
4
+ * Persists daemon namespace, tab history, and preferences across
5
+ * session reloads and branch navigation.
6
+ */
7
+
8
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+
10
+ // ── Types ────────────────────────────────────────────────────────────────────
11
+
12
+ export interface BrowserState {
13
+ /** Daemon namespace (BU_NAME) for this session */
14
+ namespace: string;
15
+ /** Recently active tab target IDs */
16
+ tabHistory: string[];
17
+ /** Cloud browser ID (for remote sessions) */
18
+ remoteBrowserId?: string;
19
+ /** Screenshot output directory */
20
+ screenshotDir: string;
21
+ /** Whether debug click overlay is enabled */
22
+ debugClicks: boolean;
23
+ }
24
+
25
+ // ── Defaults ─────────────────────────────────────────────────────────────────
26
+
27
+ export function defaultState(namespace = "default"): BrowserState {
28
+ return {
29
+ namespace,
30
+ tabHistory: [],
31
+ screenshotDir: "",
32
+ debugClicks: false,
33
+ };
34
+ }
35
+
36
+ // ── Persist ──────────────────────────────────────────────────────────────────
37
+
38
+ export function persistState(pi: ExtensionAPI, state: BrowserState): void {
39
+ pi.appendEntry<BrowserState>("browser-harness-state", state);
40
+ }
41
+
42
+ // ── Restore ──────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Find the last browser-harness-state entry in the current branch
46
+ * and return the restored state, merged with defaults.
47
+ */
48
+ export function restoreState(ctx: ExtensionContext, currentNamespace?: string): BrowserState {
49
+ const branchEntries = ctx.sessionManager.getBranch();
50
+ const defaults = defaultState(currentNamespace);
51
+
52
+ // Walk branch from newest to oldest to find last persisted state
53
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
54
+ const entry = branchEntries[i];
55
+ if (entry.type === "custom" && entry.customType === "browser-harness-state") {
56
+ const data = entry.data as Partial<BrowserState> | undefined;
57
+ if (data) {
58
+ // Merge persisted data onto defaults, but always keep the current
59
+ // namespace (which comes from the --browser-namespace flag or auto-gen).
60
+ return { ...defaults, ...data, namespace: currentNamespace || data.namespace || defaults.namespace };
61
+ }
62
+ }
63
+ }
64
+
65
+ return defaults;
66
+ }
67
+
68
+ // ── Merge helpers ────────────────────────────────────────────────────────────
69
+
70
+ export function addToTabHistory(state: BrowserState, targetId: string): BrowserState {
71
+ const filtered = state.tabHistory.filter((id) => id !== targetId);
72
+ return { ...state, tabHistory: [targetId, ...filtered].slice(0, 20) };
73
+ }
74
+
75
+ export function setRemoteBrowser(state: BrowserState, browserId: string): BrowserState {
76
+ return { ...state, remoteBrowserId: browserId };
77
+ }