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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/assets/hero.png +0 -0
- package/package.json +68 -0
- package/skills/pi-browser-harness/SKILL.md +213 -0
- package/src/daemon.ts +767 -0
- package/src/index.ts +223 -0
- package/src/prompt.ts +106 -0
- package/src/protocol.ts +77 -0
- package/src/renderers.ts +21 -0
- package/src/setup.ts +97 -0
- package/src/state.ts +77 -0
- package/src/tools.ts +1358 -0
- package/tsconfig.json +18 -0
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
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -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
|
+
}
|
package/src/renderers.ts
ADDED
|
@@ -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
|
+
}
|