pi-chrome 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/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # pi-chrome
2
+
3
+ Pi extension for controlling the Chrome profile you already use.
4
+
5
+ Unlike Chrome DevTools Protocol integrations, `pi-chrome` does not launch a separate debug browser profile. It uses a small companion Chrome extension installed in your normal Chrome profile, so Pi can work with your existing windows, tabs, and authenticated sessions.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:pi-chrome
11
+ ```
12
+
13
+ For local development from this checkout:
14
+
15
+ ```bash
16
+ pi install ./pi-chrome
17
+ ```
18
+
19
+ ## First-time setup
20
+
21
+ In Pi, run:
22
+
23
+ ```text
24
+ /chrome-onboard
25
+ ```
26
+
27
+ Pi will first show setup instructions and wait for confirmation. Press Enter to continue. On macOS it will:
28
+
29
+ - open `chrome://extensions`
30
+ - reveal the bundled `browser-extension` folder in Finder
31
+ - copy the extension folder path to your clipboard
32
+
33
+ Then in Chrome:
34
+
35
+ 1. Enable **Developer mode**.
36
+ 2. Click **Load unpacked**.
37
+ 3. Select the revealed/copied `browser-extension` folder.
38
+ 4. Return to Pi and run:
39
+
40
+ ```text
41
+ /chrome-status
42
+ ```
43
+
44
+ Expected messages:
45
+
46
+ ```text
47
+ Performing Chrome bridge health check
48
+ Chrome profile bridge connected (ID: <chrome-extension-id>)
49
+ ```
50
+
51
+ ## Tools
52
+
53
+ The package registers these Pi tools:
54
+
55
+ - `chrome_launch` — setup/help entry point; opens a URL if the bridge is connected
56
+ - `chrome_tab` — list, create, activate, close, or inspect tabs
57
+ - `chrome_snapshot` — inspect title, URL, visible text, viewport, and clickable/focusable selectors
58
+ - `chrome_navigate` — navigate an existing tab
59
+ - `chrome_evaluate` — evaluate JavaScript in a tab
60
+ - `chrome_click` — click by selector or coordinates
61
+ - `chrome_type` — type text, optionally focusing a selector first
62
+ - `chrome_key` — send keyboard keys
63
+ - `chrome_wait_for` — wait for a selector or expression
64
+ - `chrome_screenshot` — capture viewport screenshots to disk
65
+
66
+ ## Security model
67
+
68
+ The companion Chrome extension runs in the Chrome profile where you install it and has broad tab/scripting permissions. Only install it from a package source you trust.
69
+
70
+ The Pi side listens on `127.0.0.1:17318` by default. Override before starting Pi with:
71
+
72
+ ```bash
73
+ PI_CHROME_BRIDGE_PORT=17319 pi
74
+ ```
@@ -0,0 +1,14 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Pi Existing Chrome Profile Bridge",
4
+ "version": "0.1.0",
5
+ "description": "Lets Pi control tabs in this existing Chrome profile via a local bridge at 127.0.0.1.",
6
+ "permissions": ["tabs", "scripting", "storage", "activeTab", "alarms"],
7
+ "host_permissions": ["<all_urls>", "http://127.0.0.1:17318/*"],
8
+ "background": {
9
+ "service_worker": "service_worker.js"
10
+ },
11
+ "action": {
12
+ "default_title": "Pi Chrome Bridge"
13
+ }
14
+ }
@@ -0,0 +1,344 @@
1
+ const BRIDGE_URL = "http://127.0.0.1:17318";
2
+ const CLIENT_NAME = `Pi Chrome Bridge ${chrome.runtime.id}`;
3
+ const POLL_ERROR_BACKOFF_MS = 2000;
4
+ let polling = false;
5
+
6
+ function armKeepaliveAlarm() {
7
+ // MV3 service workers can be suspended; alarms are the supported way to
8
+ // wake the extension again. Chrome's minimum period is 0.5 minutes.
9
+ chrome.alarms.create("pi-bridge-keepalive", { periodInMinutes: 0.5 });
10
+ }
11
+
12
+ chrome.runtime.onInstalled.addListener(() => {
13
+ chrome.action.setBadgeText({ text: "pi" });
14
+ chrome.action.setBadgeBackgroundColor({ color: "#4f46e5" });
15
+ armKeepaliveAlarm();
16
+ void pollLoop();
17
+ });
18
+
19
+ chrome.runtime.onStartup.addListener(() => {
20
+ armKeepaliveAlarm();
21
+ void pollLoop();
22
+ });
23
+
24
+ chrome.alarms.onAlarm.addListener((alarm) => {
25
+ if (alarm.name === "pi-bridge-keepalive") void pollLoop();
26
+ });
27
+
28
+ chrome.action.onClicked.addListener(() => {
29
+ armKeepaliveAlarm();
30
+ void pollLoop();
31
+ });
32
+
33
+ armKeepaliveAlarm();
34
+
35
+ setInterval(() => {
36
+ void pollLoop();
37
+ }, 1000);
38
+
39
+ async function pollLoop() {
40
+ if (polling) return;
41
+ polling = true;
42
+ try {
43
+ while (true) {
44
+ const response = await fetch(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, {
45
+ cache: "no-store",
46
+ });
47
+ if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
48
+ const payload = await response.json();
49
+ if (payload.type !== "command") break;
50
+ await handleCommand(payload.command);
51
+ }
52
+ } catch (error) {
53
+ await sleep(POLL_ERROR_BACKOFF_MS);
54
+ } finally {
55
+ polling = false;
56
+ }
57
+ }
58
+
59
+ async function handleCommand(command) {
60
+ try {
61
+ const result = await dispatch(command.action, command.params ?? {});
62
+ await postResult({ id: command.id, ok: true, result });
63
+ } catch (error) {
64
+ await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) });
65
+ }
66
+ }
67
+
68
+ async function postResult(result) {
69
+ await fetch(`${BRIDGE_URL}/result`, {
70
+ method: "POST",
71
+ headers: { "content-type": "application/json" },
72
+ body: JSON.stringify(result),
73
+ });
74
+ }
75
+
76
+ function sleep(ms) {
77
+ return new Promise((resolve) => setTimeout(resolve, ms));
78
+ }
79
+
80
+ async function dispatch(action, params) {
81
+ switch (action) {
82
+ case "tab.version":
83
+ return { extensionId: chrome.runtime.id, bridgeUrl: BRIDGE_URL, userAgent: navigator.userAgent };
84
+ case "tab.list":
85
+ return (await chrome.tabs.query({})).map(formatTab);
86
+ case "tab.new": {
87
+ const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: true });
88
+ return formatTab(tab);
89
+ }
90
+ case "tab.activate": {
91
+ const tab = await getTabByParams(params);
92
+ await chrome.windows.update(tab.windowId, { focused: true });
93
+ return formatTab(await chrome.tabs.update(tab.id, { active: true }));
94
+ }
95
+ case "tab.close": {
96
+ const tab = await getTabByParams(params);
97
+ await chrome.tabs.remove(tab.id);
98
+ return { closed: tab.id };
99
+ }
100
+ case "page.snapshot":
101
+ return executeInTab(params, snapshotPage, [params.maxElements || 80]);
102
+ case "page.evaluate":
103
+ return executeInTab(params, evaluateExpression, [params.expression, params.awaitPromise !== false]);
104
+ case "page.click":
105
+ return executeInTab(params, clickPage, [params.selector ?? null, params.x ?? null, params.y ?? null]);
106
+ case "page.type":
107
+ return executeInTab(params, typeIntoPage, [params.selector ?? null, params.text || "", Boolean(params.pressEnter)]);
108
+ case "page.key":
109
+ return executeInTab(params, pressKeyInPage, [params.key]);
110
+ case "page.waitFor":
111
+ return executeInTab(params, waitForPage, [params.kind, params.value, params.timeoutMs || 10000, params.intervalMs || 250]);
112
+ case "page.navigate": {
113
+ const tab = await getTabByParams(params);
114
+ const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
115
+ const updated = await chrome.tabs.update(tab.id, { url: params.url, active: true });
116
+ await wait;
117
+ return formatTab(await chrome.tabs.get(updated.id));
118
+ }
119
+ case "page.screenshot": {
120
+ const tab = await getTabByParams(params);
121
+ await chrome.windows.update(tab.windowId, { focused: true });
122
+ await chrome.tabs.update(tab.id, { active: true });
123
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
124
+ format: params.format || "png",
125
+ quality: params.format === "jpeg" ? params.quality : undefined,
126
+ });
127
+ return { dataUrl, tab: formatTab(tab) };
128
+ }
129
+ default:
130
+ throw new Error(`Unknown action: ${action}`);
131
+ }
132
+ }
133
+
134
+ function formatTab(tab) {
135
+ return {
136
+ id: tab.id,
137
+ windowId: tab.windowId,
138
+ active: tab.active,
139
+ highlighted: tab.highlighted,
140
+ title: tab.title || "",
141
+ url: tab.url || "",
142
+ status: tab.status,
143
+ pinned: tab.pinned,
144
+ incognito: tab.incognito,
145
+ };
146
+ }
147
+
148
+ async function getTabByParams(params) {
149
+ const tabs = await chrome.tabs.query({});
150
+ let tab;
151
+ if (params.targetId !== undefined) {
152
+ const id = Number(params.targetId);
153
+ tab = tabs.find((candidate) => candidate.id === id);
154
+ } else if (params.urlIncludes) {
155
+ tab = tabs.find((candidate) => (candidate.url || "").includes(params.urlIncludes));
156
+ } else if (params.titleIncludes) {
157
+ tab = tabs.find((candidate) => (candidate.title || "").includes(params.titleIncludes));
158
+ } else {
159
+ const active = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
160
+ tab = active[0] || tabs.find((candidate) => candidate.active) || tabs[0];
161
+ }
162
+ if (!tab?.id) throw new Error("No matching Chrome tab found");
163
+ if ((tab.url || "").startsWith("chrome://") || (tab.url || "").startsWith("chrome-extension://")) {
164
+ throw new Error(`Chrome blocks extension automation on protected URL: ${tab.url}`);
165
+ }
166
+ return tab;
167
+ }
168
+
169
+ async function executeInTab(params, func, args) {
170
+ const tab = await getTabByParams(params);
171
+ await chrome.windows.update(tab.windowId, { focused: true });
172
+ await chrome.tabs.update(tab.id, { active: true });
173
+ const results = await chrome.scripting.executeScript({
174
+ target: { tabId: tab.id },
175
+ world: "MAIN",
176
+ func,
177
+ args,
178
+ });
179
+ return results?.[0]?.result;
180
+ }
181
+
182
+ function waitForTabComplete(tabId, timeoutMs) {
183
+ return new Promise((resolve, reject) => {
184
+ const timer = setTimeout(() => {
185
+ chrome.tabs.onUpdated.removeListener(listener);
186
+ reject(new Error(`Timed out after ${timeoutMs}ms waiting for tab ${tabId} to load`));
187
+ }, timeoutMs);
188
+ const listener = (updatedTabId, changeInfo) => {
189
+ if (updatedTabId === tabId && changeInfo.status === "complete") {
190
+ clearTimeout(timer);
191
+ chrome.tabs.onUpdated.removeListener(listener);
192
+ resolve(true);
193
+ }
194
+ };
195
+ chrome.tabs.onUpdated.addListener(listener);
196
+ });
197
+ }
198
+
199
+ function snapshotPage(maxElements) {
200
+ const unique = (selector) => {
201
+ try { return document.querySelectorAll(selector).length === 1; } catch { return false; }
202
+ };
203
+ const cssEscape = (value) => (window.CSS && CSS.escape) ? CSS.escape(value) : String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
204
+ const selectorFor = (element) => {
205
+ if (element.id && unique("#" + cssEscape(element.id))) return "#" + cssEscape(element.id);
206
+ const attr = ["aria-label", "name", "placeholder", "data-testid", "role"].find((name) => element.getAttribute(name));
207
+ if (attr) {
208
+ const candidate = element.tagName.toLowerCase() + "[" + attr + "=" + JSON.stringify(element.getAttribute(attr)) + "]";
209
+ if (unique(candidate)) return candidate;
210
+ }
211
+ const parts = [];
212
+ let current = element;
213
+ while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 5) {
214
+ let part = current.tagName.toLowerCase();
215
+ if (current.classList.length > 0) part += "." + Array.from(current.classList).slice(0, 2).map(cssEscape).join(".");
216
+ const siblings = Array.from(current.parentElement?.children ?? []).filter((sibling) => sibling.tagName === current.tagName);
217
+ if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")";
218
+ parts.unshift(part);
219
+ const candidate = parts.join(" > ");
220
+ if (unique(candidate)) return candidate;
221
+ current = current.parentElement;
222
+ }
223
+ return parts.join(" > ");
224
+ };
225
+ const visible = (element) => {
226
+ const style = getComputedStyle(element);
227
+ const rect = element.getBoundingClientRect();
228
+ return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
229
+ };
230
+ const labelFor = (element) => (
231
+ element.getAttribute("aria-label") ||
232
+ element.getAttribute("title") ||
233
+ element.getAttribute("placeholder") ||
234
+ element.innerText ||
235
+ element.value ||
236
+ element.textContent ||
237
+ ""
238
+ ).trim().replace(/\s+/g, " ").slice(0, 160);
239
+ const candidates = Array.from(document.querySelectorAll('a, button, input, textarea, select, summary, [role="button"], [role="link"], [contenteditable="true"], [tabindex]:not([tabindex="-1"])'));
240
+ const elements = candidates.filter(visible).slice(0, maxElements).map((element, index) => {
241
+ const rect = element.getBoundingClientRect();
242
+ return {
243
+ index,
244
+ tag: element.tagName.toLowerCase(),
245
+ selector: selectorFor(element),
246
+ label: labelFor(element),
247
+ href: element.href || undefined,
248
+ type: element.getAttribute("type") || undefined,
249
+ role: element.getAttribute("role") || undefined,
250
+ disabled: Boolean(element.disabled || element.getAttribute("aria-disabled") === "true"),
251
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
252
+ };
253
+ });
254
+ return {
255
+ title: document.title,
256
+ url: location.href,
257
+ viewport: { width: innerWidth, height: innerHeight, scrollX, scrollY },
258
+ text: document.body ? document.body.innerText.replace(/\s+\n/g, "\n").trim().slice(0, 30000) : "",
259
+ elements,
260
+ };
261
+ }
262
+
263
+ async function evaluateExpression(expression, awaitPromise) {
264
+ const indirectEval = (0, eval);
265
+ const value = indirectEval(expression);
266
+ return awaitPromise && value && typeof value.then === "function" ? await value : value;
267
+ }
268
+
269
+ function resolvePoint(selector, x, y) {
270
+ if (selector) {
271
+ const element = document.querySelector(selector);
272
+ if (!element) throw new Error(`No element matches selector: ${selector}`);
273
+ element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
274
+ const rect = element.getBoundingClientRect();
275
+ return { element, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, rect };
276
+ }
277
+ if (typeof x !== "number" || typeof y !== "number") throw new Error("Provide selector or x/y");
278
+ return { element: document.elementFromPoint(x, y), x, y, rect: undefined };
279
+ }
280
+
281
+ function clickPage(selector, x, y) {
282
+ const point = resolvePoint(selector, x, y);
283
+ if (!point.element) throw new Error("No element at click point");
284
+ for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
285
+ point.element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: point.x, clientY: point.y, button: 0 }));
286
+ }
287
+ return { x: point.x, y: point.y, selector, tag: point.element.tagName };
288
+ }
289
+
290
+ function typeIntoPage(selector, text, pressEnter) {
291
+ let element = selector ? document.querySelector(selector) : document.activeElement;
292
+ if (!element) throw new Error(selector ? `No element matches selector: ${selector}` : "No active element");
293
+ element.focus();
294
+ if (element.isContentEditable) {
295
+ document.execCommand("insertText", false, text);
296
+ } else if ("value" in element) {
297
+ const start = element.selectionStart ?? element.value.length;
298
+ const end = element.selectionEnd ?? element.value.length;
299
+ element.value = element.value.slice(0, start) + text + element.value.slice(end);
300
+ element.selectionStart = element.selectionEnd = start + text.length;
301
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
302
+ element.dispatchEvent(new Event("change", { bubbles: true }));
303
+ } else {
304
+ throw new Error("Focused element is not text-editable");
305
+ }
306
+ if (pressEnter) pressKeyInPage("Enter");
307
+ return { selector, length: text.length, pressEnter };
308
+ }
309
+
310
+ function pressKeyInPage(key) {
311
+ const target = document.activeElement || document.body;
312
+ const normalized = normalizeKey(key);
313
+ target.dispatchEvent(new KeyboardEvent("keydown", { key: normalized, bubbles: true, cancelable: true }));
314
+ target.dispatchEvent(new KeyboardEvent("keyup", { key: normalized, bubbles: true, cancelable: true }));
315
+ if (normalized === "Enter" && target instanceof HTMLFormElement) target.requestSubmit();
316
+ return { key: normalized };
317
+ }
318
+
319
+ async function waitForPage(kind, value, timeoutMs, intervalMs) {
320
+ const started = Date.now();
321
+ while (Date.now() - started < timeoutMs) {
322
+ let ok = false;
323
+ if (kind === "selector") ok = Boolean(document.querySelector(value));
324
+ else ok = Boolean((0, eval)(value));
325
+ if (ok) return { elapsedMs: Date.now() - started };
326
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
327
+ }
328
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for ${kind}: ${value}`);
329
+ }
330
+
331
+ function normalizeKey(key) {
332
+ const table = {
333
+ enter: "Enter",
334
+ escape: "Escape",
335
+ tab: "Tab",
336
+ backspace: "Backspace",
337
+ delete: "Delete",
338
+ arrowup: "ArrowUp",
339
+ arrowdown: "ArrowDown",
340
+ arrowleft: "ArrowLeft",
341
+ arrowright: "ArrowRight",
342
+ };
343
+ return table[String(key).toLowerCase()] || key;
344
+ }
@@ -0,0 +1,550 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type } from "typebox";
4
+ import { existsSync, statSync } from "node:fs";
5
+ import { mkdir, writeFile } from "node:fs/promises";
6
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
7
+ import { dirname, join, resolve } from "node:path";
8
+
9
+ /**
10
+ * Existing-profile Chrome bridge for pi.
11
+ *
12
+ * This is intentionally not a Chrome DevTools Protocol integration. CDP cannot attach to
13
+ * already-running normal Chrome windows and recent Chrome builds block default-profile
14
+ * remote debugging. Instead, install the companion Chrome extension from the
15
+ * browser-extension folder bundled next to this Pi extension.
16
+ *
17
+ * The companion extension runs inside the user's real Chrome profile and polls this local
18
+ * pi extension for commands. That gives pi access to the user's existing tabs/authenticated
19
+ * profile, subject to the browser extension permissions the user grants.
20
+ */
21
+
22
+ type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
23
+
24
+ type ToolTextResult = {
25
+ content: Array<{ type: "text"; text: string }>;
26
+ details?: Record<string, unknown>;
27
+ };
28
+
29
+ type BridgeCommand = {
30
+ id: string;
31
+ action: string;
32
+ params: Record<string, unknown>;
33
+ };
34
+
35
+ type PendingCommand = {
36
+ command: BridgeCommand;
37
+ resolve: (value: unknown) => void;
38
+ reject: (error: Error) => void;
39
+ timer: NodeJS.Timeout;
40
+ };
41
+
42
+ type BridgeResult = {
43
+ id: string;
44
+ ok: boolean;
45
+ result?: unknown;
46
+ error?: string;
47
+ };
48
+
49
+ const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1";
50
+ const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318");
51
+ const DEFAULT_TIMEOUT_MS = 30_000;
52
+ const MAX_TEXT_CHARS = 30_000;
53
+ const MAX_ELEMENTS = 80;
54
+
55
+ function truncateText(text: string, maxChars = MAX_TEXT_CHARS): string {
56
+ if (text.length <= maxChars) return text;
57
+ return `${text.slice(0, maxChars)}\n\n[truncated ${text.length - maxChars} characters]`;
58
+ }
59
+
60
+ function safeJson(value: unknown): string {
61
+ return JSON.stringify(value, null, 2);
62
+ }
63
+
64
+ function extensionRoot(): string {
65
+ // Resolve relative to this extension file, not ctx.cwd. ctx.cwd can temporarily be
66
+ // an attachment/clipboard path when Pi is handling pasted images.
67
+ if (typeof __dirname === "string") return __dirname;
68
+ return process.cwd();
69
+ }
70
+
71
+ function workspaceCwd(ctx: ExtensionContext): string {
72
+ for (const candidate of [ctx.cwd, process.cwd()]) {
73
+ if (!candidate) continue;
74
+ try {
75
+ if (existsSync(candidate) && statSync(candidate).isDirectory()) return candidate;
76
+ } catch {
77
+ // try next candidate
78
+ }
79
+ }
80
+ return process.cwd();
81
+ }
82
+
83
+ function browserExtensionPath(): string {
84
+ return join(extensionRoot(), "browser-extension");
85
+ }
86
+
87
+ function readRequestBody(request: IncomingMessage): Promise<string> {
88
+ return new Promise((resolveBody, rejectBody) => {
89
+ const chunks: Buffer[] = [];
90
+ request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
91
+ request.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
92
+ request.on("error", rejectBody);
93
+ });
94
+ }
95
+
96
+ function sendJson(response: ServerResponse, status: number, body: unknown): void {
97
+ response.writeHead(status, {
98
+ "content-type": "application/json; charset=utf-8",
99
+ "access-control-allow-origin": "*",
100
+ "access-control-allow-methods": "GET,POST,OPTIONS",
101
+ "access-control-allow-headers": "content-type",
102
+ "cache-control": "no-store",
103
+ });
104
+ response.end(JSON.stringify(body));
105
+ }
106
+
107
+ class ChromeProfileBridge {
108
+ private server: Server | undefined;
109
+ private pending = new Map<string, PendingCommand>();
110
+ private queue: BridgeCommand[] = [];
111
+ private waiters: Array<(command: BridgeCommand | undefined) => void> = [];
112
+ private lastSeenAt: number | undefined;
113
+ private clientName: string | undefined;
114
+
115
+ constructor(
116
+ private readonly host: string,
117
+ private readonly port: number,
118
+ ) {}
119
+
120
+ get url(): string {
121
+ return `http://${this.host}:${this.port}`;
122
+ }
123
+
124
+ get connected(): boolean {
125
+ // MV3 service workers can pause between polls/alarms. Treat a recent poll as
126
+ // connected without sending a synthetic command; real chrome_* tool calls are
127
+ // the authoritative end-to-end health check.
128
+ return this.lastSeenAt !== undefined && Date.now() - this.lastSeenAt < 5 * 60_000;
129
+ }
130
+
131
+ status(): Record<string, unknown> {
132
+ return {
133
+ url: this.url,
134
+ connected: this.connected,
135
+ lastSeenAt: this.lastSeenAt,
136
+ clientName: this.clientName,
137
+ queuedCommands: this.queue.length,
138
+ pendingCommands: this.pending.size,
139
+ };
140
+ }
141
+
142
+ async start(): Promise<void> {
143
+ if (this.server) return;
144
+ this.server = createServer((request, response) => {
145
+ void this.handle(request, response).catch((error) => {
146
+ sendJson(response, 500, { error: (error as Error).message });
147
+ });
148
+ });
149
+ await new Promise<void>((resolveStart, rejectStart) => {
150
+ this.server!.once("error", rejectStart);
151
+ this.server!.listen(this.port, this.host, () => {
152
+ this.server!.off("error", rejectStart);
153
+ resolveStart();
154
+ });
155
+ });
156
+ }
157
+
158
+ stop(): void {
159
+ for (const pending of this.pending.values()) {
160
+ clearTimeout(pending.timer);
161
+ pending.reject(new Error("Chrome profile bridge stopped"));
162
+ }
163
+ this.pending.clear();
164
+ this.queue = [];
165
+ for (const waiter of this.waiters) waiter(undefined);
166
+ this.waiters = [];
167
+ this.server?.close();
168
+ this.server = undefined;
169
+ }
170
+
171
+ send(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
172
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
173
+ const command = { id, action, params };
174
+ return new Promise((resolveCommand, rejectCommand) => {
175
+ const timer = setTimeout(() => {
176
+ this.pending.delete(id);
177
+ this.queue = this.queue.filter((queued) => queued.id !== id);
178
+ rejectCommand(
179
+ new Error(
180
+ `Timed out waiting for Chrome extension after ${timeoutMs}ms. Run /chrome-onboard, then load the bundled browser-extension folder in your normal Chrome profile.`,
181
+ ),
182
+ );
183
+ }, timeoutMs);
184
+ this.pending.set(id, { command, resolve: resolveCommand, reject: rejectCommand, timer });
185
+ this.enqueue(command);
186
+ });
187
+ }
188
+
189
+ private enqueue(command: BridgeCommand): void {
190
+ const waiter = this.waiters.shift();
191
+ if (waiter) waiter(command);
192
+ else this.queue.push(command);
193
+ }
194
+
195
+ private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
196
+ if (request.method === "OPTIONS") {
197
+ sendJson(response, 200, { ok: true });
198
+ return;
199
+ }
200
+ const url = new URL(request.url ?? "/", this.url);
201
+ if (request.method === "GET" && url.pathname === "/status") {
202
+ sendJson(response, 200, this.status());
203
+ return;
204
+ }
205
+ if (request.method === "GET" && url.pathname === "/next") {
206
+ this.lastSeenAt = Date.now();
207
+ this.clientName = url.searchParams.get("name") ?? undefined;
208
+ const command = this.queue.shift() ?? (await this.waitForCommand(25_000));
209
+ sendJson(response, 200, command ? { type: "command", command } : { type: "none" });
210
+ return;
211
+ }
212
+ if (request.method === "POST" && url.pathname === "/result") {
213
+ this.lastSeenAt = Date.now();
214
+ const result = JSON.parse(await readRequestBody(request)) as BridgeResult;
215
+ const pending = this.pending.get(result.id);
216
+ if (!pending) {
217
+ sendJson(response, 404, { ok: false, error: "unknown command id" });
218
+ return;
219
+ }
220
+ clearTimeout(pending.timer);
221
+ this.pending.delete(result.id);
222
+ if (result.ok) pending.resolve(result.result);
223
+ else pending.reject(new Error(result.error ?? "Chrome extension command failed"));
224
+ sendJson(response, 200, { ok: true });
225
+ return;
226
+ }
227
+ sendJson(response, 404, { error: "not found" });
228
+ }
229
+
230
+ private waitForCommand(timeoutMs: number): Promise<BridgeCommand | undefined> {
231
+ return new Promise((resolveWait) => {
232
+ const timer = setTimeout(() => {
233
+ this.waiters = this.waiters.filter((waiter) => waiter !== resolveWait);
234
+ resolveWait(undefined);
235
+ }, timeoutMs);
236
+ this.waiters.push((command) => {
237
+ clearTimeout(timer);
238
+ resolveWait(command);
239
+ });
240
+ });
241
+ }
242
+ }
243
+
244
+ const tabActionValues = ["list", "new", "activate", "close", "version"] as const;
245
+ const imageFormatValues = ["png", "jpeg"] as const;
246
+ const waitForValues = ["selector", "expression"] as const;
247
+
248
+ export default function (pi: ExtensionAPI): void {
249
+ const bridge = new ChromeProfileBridge(DEFAULT_HOST, DEFAULT_PORT);
250
+
251
+ pi.on("session_start", async (_event, ctx) => {
252
+ await bridge.start();
253
+ ctx.ui.setStatus("chrome", `Chrome bridge :${DEFAULT_PORT}`);
254
+ ctx.ui.notify(
255
+ `Chrome profile bridge listening at ${bridge.url}. Run /chrome-onboard to load the bundled browser extension in your normal Chrome profile.`,
256
+ "info",
257
+ );
258
+ });
259
+
260
+ pi.on("session_shutdown", () => {
261
+ bridge.stop();
262
+ });
263
+
264
+ pi.on("before_agent_start", (event) => {
265
+ const primer = `
266
+ <chrome-profile-bridge>
267
+ Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile.
268
+ This is not CDP: it can use the user's existing Chrome windows and authenticated sessions after the user loads the companion browser extension.
269
+ If chrome_* tools time out, ask the user to run /chrome-onboard, then load the bundled browser-extension folder in chrome://extensions. Prefer chrome_snapshot before clicking/typing. Avoid destructive actions unless explicitly requested.
270
+ </chrome-profile-bridge>`;
271
+ return { systemPrompt: event.systemPrompt + primer };
272
+ });
273
+
274
+ pi.registerCommand("chrome-status", {
275
+ description: "Run an explicit health check against the existing-profile Chrome companion extension",
276
+ handler: async (_args, ctx) => {
277
+ ctx.ui.notify("Performing Chrome bridge health check", "info");
278
+ try {
279
+ const version = (await bridge.send("tab.version", {}, 35_000)) as { extensionId?: string };
280
+ ctx.ui.notify(
281
+ version.extensionId
282
+ ? `Chrome profile bridge connected (ID: ${version.extensionId})`
283
+ : "Chrome profile bridge connected",
284
+ "info",
285
+ );
286
+ } catch {
287
+ ctx.ui.notify("Chrome bridge health check timed out. Run /chrome-onboard to connect Chrome.", "warning");
288
+ }
289
+ },
290
+ });
291
+
292
+ pi.registerCommand("chrome-onboard", {
293
+ description: "Guide Chrome extension setup for the existing-profile bridge",
294
+ handler: async (_args, ctx) => {
295
+ const extensionPath = browserExtensionPath();
296
+ const proceed = await ctx.ui.confirm(
297
+ "Chrome bridge setup",
298
+ `This will open chrome://extensions and reveal the extension folder in Finder.\n\nAfter the windows open: enable Developer mode → Load unpacked → select:\n${extensionPath}\n\nPress Enter to continue, or Esc to cancel.`,
299
+ );
300
+ if (!proceed) {
301
+ ctx.ui.notify("Chrome bridge setup cancelled", "info");
302
+ return;
303
+ }
304
+ if (process.platform === "darwin") {
305
+ await pi.exec("open", ["-a", "Google Chrome", "chrome://extensions"], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
306
+ await pi.exec("open", ["-R", join(extensionPath, "manifest.json")], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
307
+ await pi.exec("sh", ["-lc", `printf %s ${JSON.stringify(extensionPath)} | pbcopy`], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
308
+ }
309
+ ctx.ui.notify(
310
+ "Chrome bridge setup opened. The extension path has been copied to your clipboard. After loading it, run /chrome-status.",
311
+ "info",
312
+ );
313
+ },
314
+ });
315
+
316
+ pi.registerTool({
317
+ name: "chrome_launch",
318
+ label: "Chrome Bridge Setup",
319
+ description:
320
+ "Start/check the local bridge used by the companion Chrome extension. This does not launch a separate Chrome profile; install the unpacked Chrome extension in your existing Chrome profile to connect.",
321
+ promptSnippet: "Show instructions for connecting Pi to the user's existing Chrome profile via the companion extension.",
322
+ parameters: Type.Object({
323
+ port: Type.Optional(Type.Number({ description: "Ignored unless PI_CHROME_BRIDGE_PORT is set before Pi starts." })),
324
+ url: Type.Optional(Type.String({ description: "Optional URL to open in the existing Chrome profile after the extension is connected." })),
325
+ userDataDir: Type.Optional(Type.String({ description: "Ignored. This bridge intentionally uses the user's existing Chrome profile through the companion extension." })),
326
+ useDefaultProfile: Type.Optional(Type.Boolean({ description: "Ignored; existing-profile access comes from the companion Chrome extension." })),
327
+ headless: Type.Optional(Type.Boolean({ description: "Ignored." })),
328
+ }),
329
+ async execute(_id, params, _signal, _onUpdate, ctx): Promise<ToolTextResult> {
330
+ if (params.url && bridge.connected) {
331
+ const result = await bridge.send("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS);
332
+ return { content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }], details: { status: bridge.status(), result } };
333
+ }
334
+ return {
335
+ content: [
336
+ {
337
+ type: "text",
338
+ text:
339
+ `Chrome profile bridge is listening at ${bridge.url}.\n\n` +
340
+ `To connect your existing Chrome profile:\n` +
341
+ `1. Open chrome://extensions in the Chrome profile you normally use.\n` +
342
+ `2. Enable Developer mode.\n` +
343
+ `3. Click “Load unpacked”.\n` +
344
+ `4. Select: ${browserExtensionPath()}\n\n` +
345
+ `Status: ${bridge.connected ? "connected" : "waiting for extension"}.`,
346
+ },
347
+ ],
348
+ details: { status: bridge.status(), extensionPath: browserExtensionPath() },
349
+ };
350
+ },
351
+ });
352
+
353
+ pi.registerTool({
354
+ name: "chrome_tab",
355
+ label: "Chrome Tab",
356
+ description: "List, create, activate, close, or inspect tabs in the user's existing Chrome profile via the companion extension.",
357
+ promptSnippet: "List/open/activate/close existing Chrome tabs through the companion extension.",
358
+ parameters: Type.Object({
359
+ action: StringEnum(tabActionValues),
360
+ url: Type.Optional(Type.String({ description: "URL for action=new." })),
361
+ targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close." })),
362
+ host: Type.Optional(Type.String()),
363
+ port: Type.Optional(Type.Number()),
364
+ }),
365
+ async execute(_id, params): Promise<ToolTextResult> {
366
+ const result = await bridge.send(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS);
367
+ if (params.action === "list") {
368
+ const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number }>;
369
+ const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
370
+ return { content: [{ type: "text", text }], details: { tabs } };
371
+ }
372
+ return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
373
+ },
374
+ });
375
+
376
+ pi.registerTool({
377
+ name: "chrome_snapshot",
378
+ label: "Chrome Snapshot",
379
+ description:
380
+ "Inspect a page in the user's existing Chrome profile: title, URL, visible body text, viewport, and clickable/focusable elements with CSS selectors.",
381
+ promptSnippet: "Inspect the current Chrome page and get CSS selectors for browser automation.",
382
+ parameters: Type.Object({
383
+ targetId: Type.Optional(Type.String()),
384
+ urlIncludes: Type.Optional(Type.String()),
385
+ titleIncludes: Type.Optional(Type.String()),
386
+ maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS })),
387
+ host: Type.Optional(Type.String()),
388
+ port: Type.Optional(Type.Number()),
389
+ }),
390
+ async execute(_id, params): Promise<ToolTextResult> {
391
+ const snapshot = await bridge.send("page.snapshot", { ...params, maxElements: params.maxElements ?? MAX_ELEMENTS }, DEFAULT_TIMEOUT_MS);
392
+ return { content: [{ type: "text", text: truncateText(safeJson(snapshot)) }], details: { snapshot } };
393
+ },
394
+ });
395
+
396
+ pi.registerTool({
397
+ name: "chrome_navigate",
398
+ label: "Chrome Navigate",
399
+ description: "Navigate an existing Chrome tab to a URL via the companion extension. Optionally waits for load completion.",
400
+ promptSnippet: "Navigate a Chrome tab in the user's existing profile.",
401
+ parameters: Type.Object({
402
+ url: Type.String(),
403
+ targetId: Type.Optional(Type.String()),
404
+ urlIncludes: Type.Optional(Type.String()),
405
+ titleIncludes: Type.Optional(Type.String()),
406
+ waitUntilLoad: Type.Optional(Type.Boolean({ default: true })),
407
+ timeoutMs: Type.Optional(Type.Number({ default: 15_000 })),
408
+ host: Type.Optional(Type.String()),
409
+ port: Type.Optional(Type.Number()),
410
+ }),
411
+ async execute(_id, params): Promise<ToolTextResult> {
412
+ const result = await bridge.send("page.navigate", params, params.timeoutMs ?? 15_000);
413
+ return { content: [{ type: "text", text: `Navigated to ${params.url}` }], details: { result: result as Json } };
414
+ },
415
+ });
416
+
417
+ pi.registerTool({
418
+ name: "chrome_evaluate",
419
+ label: "Chrome Evaluate",
420
+ description:
421
+ "Evaluate JavaScript in an existing Chrome tab through the companion extension. Runs in the page context and returns JSON-serializable values when possible.",
422
+ promptSnippet: "Evaluate JavaScript in the active Chrome tab through the companion extension.",
423
+ parameters: Type.Object({
424
+ expression: Type.String(),
425
+ awaitPromise: Type.Optional(Type.Boolean({ default: true })),
426
+ returnByValue: Type.Optional(Type.Boolean({ default: true })),
427
+ targetId: Type.Optional(Type.String()),
428
+ urlIncludes: Type.Optional(Type.String()),
429
+ titleIncludes: Type.Optional(Type.String()),
430
+ host: Type.Optional(Type.String()),
431
+ port: Type.Optional(Type.Number()),
432
+ }),
433
+ async execute(_id, params): Promise<ToolTextResult> {
434
+ const value = await bridge.send("page.evaluate", params, DEFAULT_TIMEOUT_MS);
435
+ return { content: [{ type: "text", text: truncateText(typeof value === "string" ? value : safeJson(value)) }], details: { value: value as Json } };
436
+ },
437
+ });
438
+
439
+ pi.registerTool({
440
+ name: "chrome_click",
441
+ label: "Chrome Click",
442
+ description: "Click a CSS selector or viewport coordinate in an existing Chrome tab through the companion extension.",
443
+ promptSnippet: "Click page elements in Chrome by selector or viewport coordinate.",
444
+ parameters: Type.Object({
445
+ selector: Type.Optional(Type.String({ description: "CSS selector to click. Prefer selectors from chrome_snapshot." })),
446
+ x: Type.Optional(Type.Number({ description: "Viewport x coordinate if selector is omitted." })),
447
+ y: Type.Optional(Type.Number({ description: "Viewport y coordinate if selector is omitted." })),
448
+ targetId: Type.Optional(Type.String()),
449
+ urlIncludes: Type.Optional(Type.String()),
450
+ titleIncludes: Type.Optional(Type.String()),
451
+ host: Type.Optional(Type.String()),
452
+ port: Type.Optional(Type.Number()),
453
+ }),
454
+ async execute(_id, params): Promise<ToolTextResult> {
455
+ const result = await bridge.send("page.click", params, DEFAULT_TIMEOUT_MS);
456
+ return { content: [{ type: "text", text: `Clicked ${params.selector ?? `${params.x},${params.y}`}` }], details: { result: result as Json } };
457
+ },
458
+ });
459
+
460
+ pi.registerTool({
461
+ name: "chrome_type",
462
+ label: "Chrome Type",
463
+ description: "Focus an optional CSS selector, then type text into an existing Chrome tab through the companion extension.",
464
+ promptSnippet: "Type text into Chrome, optionally focusing a selector first.",
465
+ parameters: Type.Object({
466
+ text: Type.String(),
467
+ selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })),
468
+ pressEnter: Type.Optional(Type.Boolean()),
469
+ targetId: Type.Optional(Type.String()),
470
+ urlIncludes: Type.Optional(Type.String()),
471
+ titleIncludes: Type.Optional(Type.String()),
472
+ host: Type.Optional(Type.String()),
473
+ port: Type.Optional(Type.Number()),
474
+ }),
475
+ async execute(_id, params): Promise<ToolTextResult> {
476
+ const result = await bridge.send("page.type", params, DEFAULT_TIMEOUT_MS);
477
+ return { content: [{ type: "text", text: `Typed ${params.text.length} character(s)${params.selector ? ` into ${params.selector}` : ""}.` }], details: { result: result as Json } };
478
+ },
479
+ });
480
+
481
+ pi.registerTool({
482
+ name: "chrome_key",
483
+ label: "Chrome Key",
484
+ description: "Send a keyboard key to an existing Chrome tab (Enter, Escape, Tab, Backspace, Delete, ArrowUp/Down/Left/Right, or one character).",
485
+ promptSnippet: "Press keys in Chrome through the companion extension.",
486
+ parameters: Type.Object({
487
+ key: Type.String(),
488
+ targetId: Type.Optional(Type.String()),
489
+ urlIncludes: Type.Optional(Type.String()),
490
+ titleIncludes: Type.Optional(Type.String()),
491
+ host: Type.Optional(Type.String()),
492
+ port: Type.Optional(Type.Number()),
493
+ }),
494
+ async execute(_id, params): Promise<ToolTextResult> {
495
+ const result = await bridge.send("page.key", params, DEFAULT_TIMEOUT_MS);
496
+ return { content: [{ type: "text", text: `Pressed ${params.key}.` }], details: { result: result as Json } };
497
+ },
498
+ });
499
+
500
+ pi.registerTool({
501
+ name: "chrome_wait_for",
502
+ label: "Chrome Wait For",
503
+ description: "Poll an existing Chrome tab until a selector exists or a JavaScript expression returns truthy.",
504
+ promptSnippet: "Wait for page state in Chrome before further automation.",
505
+ parameters: Type.Object({
506
+ kind: StringEnum(waitForValues),
507
+ value: Type.String({ description: "CSS selector when kind=selector; JavaScript expression when kind=expression." }),
508
+ timeoutMs: Type.Optional(Type.Number({ default: 10_000 })),
509
+ intervalMs: Type.Optional(Type.Number({ default: 250 })),
510
+ targetId: Type.Optional(Type.String()),
511
+ urlIncludes: Type.Optional(Type.String()),
512
+ titleIncludes: Type.Optional(Type.String()),
513
+ host: Type.Optional(Type.String()),
514
+ port: Type.Optional(Type.Number()),
515
+ }),
516
+ async execute(_id, params): Promise<ToolTextResult> {
517
+ const result = await bridge.send("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000);
518
+ return { content: [{ type: "text", text: `Observed ${params.kind}: ${params.value}` }], details: { result: result as Json } };
519
+ },
520
+ });
521
+
522
+ pi.registerTool({
523
+ name: "chrome_screenshot",
524
+ label: "Chrome Screenshot",
525
+ description: "Capture a screenshot of an existing Chrome tab via the companion extension and save it to disk.",
526
+ promptSnippet: "Capture Chrome screenshots and save them under .pi/chrome-screenshots by default.",
527
+ parameters: Type.Object({
528
+ path: Type.Optional(Type.String({ description: "Output path. Defaults to .pi/chrome-screenshots/<timestamp>.<format>." })),
529
+ format: Type.Optional(StringEnum(imageFormatValues)),
530
+ quality: Type.Optional(Type.Number({ description: "JPEG quality 0-100." })),
531
+ fullPage: Type.Optional(Type.Boolean({ description: "Not supported by the extension bridge yet; viewport screenshots are captured." })),
532
+ targetId: Type.Optional(Type.String()),
533
+ urlIncludes: Type.Optional(Type.String()),
534
+ titleIncludes: Type.Optional(Type.String()),
535
+ host: Type.Optional(Type.String()),
536
+ port: Type.Optional(Type.Number()),
537
+ }),
538
+ async execute(_id, params, _signal, _onUpdate, ctx: ExtensionContext): Promise<ToolTextResult> {
539
+ const format = params.format ?? "png";
540
+ const cwd = workspaceCwd(ctx);
541
+ const defaultPath = join(cwd, ".pi", "chrome-screenshots", `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`);
542
+ const outputPath = params.path ? resolve(cwd, params.path) : defaultPath;
543
+ const result = (await bridge.send("page.screenshot", params, DEFAULT_TIMEOUT_MS)) as { dataUrl: string; tab?: unknown };
544
+ const base64 = result.dataUrl.replace(/^data:image\/(?:png|jpeg);base64,/, "");
545
+ await mkdir(dirname(outputPath), { recursive: true });
546
+ await writeFile(outputPath, Buffer.from(base64, "base64"));
547
+ return { content: [{ type: "text", text: `Saved Chrome screenshot to ${outputPath}` }], details: { path: outputPath, format, tab: result.tab } };
548
+ },
549
+ });
550
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "pi-chrome",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for controlling the user's existing Chrome profile through a companion browser extension.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "chrome",
9
+ "browser",
10
+ "automation"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "commonjs",
14
+ "files": [
15
+ "extensions",
16
+ "README.md"
17
+ ],
18
+ "pi": {
19
+ "extensions": [
20
+ "./extensions/chrome-profile-bridge/index.ts"
21
+ ]
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-ai": "*",
25
+ "@earendil-works/pi-coding-agent": "*",
26
+ "typebox": "*"
27
+ }
28
+ }