pi-chrome 0.1.2 → 0.2.1
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
CHANGED
|
@@ -4,6 +4,8 @@ Control the Chrome profile you already use from Pi.
|
|
|
4
4
|
|
|
5
5
|
`pi-chrome` gives Pi agents browser tools for your **real Chrome windows, tabs, and authenticated sessions**. It uses a companion Chrome extension instead of the Chrome DevTools Protocol (CDP), so it does not launch a throwaway debug browser profile and does not require re-signing into the apps you already have open.
|
|
6
6
|
|
|
7
|
+
Multiple Pi sessions can use Chrome at the same time. The first Pi session starts the local bridge; later sessions automatically detect that bridge and submit their Chrome commands through it.
|
|
8
|
+
|
|
7
9
|
## Why try it?
|
|
8
10
|
|
|
9
11
|
- **Uses your existing Chrome profile** — works with the Chrome windows/tabs you are already using, including logged-in GitHub, admin dashboards, local apps, and internal tools.
|
|
@@ -89,6 +91,8 @@ These tools are especially useful for authenticated web app debugging, repro flo
|
|
|
89
91
|
|
|
90
92
|
Pi starts a local bridge on `127.0.0.1:17318`. The companion Chrome extension, installed in your normal Chrome profile, polls that local bridge for commands and executes them using Chrome extension APIs.
|
|
91
93
|
|
|
94
|
+
If another Pi session is already running the bridge, additional Pi sessions automatically act as clients of that shared bridge. This lets planner/audit/worker sessions all use the same authenticated Chrome profile concurrently without fighting over the port.
|
|
95
|
+
|
|
92
96
|
This is intentionally different from CDP-based tools: the browser extension lives inside the profile you already use, so Pi can interact with existing tabs and authenticated page state.
|
|
93
97
|
|
|
94
98
|
## Security model
|
|
@@ -111,6 +111,7 @@ class ChromeProfileBridge {
|
|
|
111
111
|
private waiters: Array<(command: BridgeCommand | undefined) => void> = [];
|
|
112
112
|
private lastSeenAt: number | undefined;
|
|
113
113
|
private clientName: string | undefined;
|
|
114
|
+
private mode: "server" | "client" | undefined;
|
|
114
115
|
|
|
115
116
|
constructor(
|
|
116
117
|
private readonly host: string,
|
|
@@ -131,6 +132,7 @@ class ChromeProfileBridge {
|
|
|
131
132
|
status(): Record<string, unknown> {
|
|
132
133
|
return {
|
|
133
134
|
url: this.url,
|
|
135
|
+
mode: this.mode ?? "starting",
|
|
134
136
|
connected: this.connected,
|
|
135
137
|
lastSeenAt: this.lastSeenAt,
|
|
136
138
|
clientName: this.clientName,
|
|
@@ -140,22 +142,36 @@ class ChromeProfileBridge {
|
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
async start(): Promise<void> {
|
|
143
|
-
if (this.server) return;
|
|
145
|
+
if (this.server || this.mode === "client") return;
|
|
144
146
|
this.server = createServer((request, response) => {
|
|
145
147
|
void this.handle(request, response).catch((error) => {
|
|
146
148
|
sendJson(response, 500, { error: (error as Error).message });
|
|
147
149
|
});
|
|
148
150
|
});
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
this.server!.
|
|
153
|
-
|
|
151
|
+
try {
|
|
152
|
+
await new Promise<void>((resolveStart, rejectStart) => {
|
|
153
|
+
this.server!.once("error", rejectStart);
|
|
154
|
+
this.server!.listen(this.port, this.host, () => {
|
|
155
|
+
this.server!.off("error", rejectStart);
|
|
156
|
+
resolveStart();
|
|
157
|
+
});
|
|
154
158
|
});
|
|
155
|
-
|
|
159
|
+
this.mode = "server";
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.server.close();
|
|
162
|
+
this.server = undefined;
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
|
164
|
+
// Another Pi session already owns the bridge port. Use it as the shared
|
|
165
|
+
// machine-local broker so multiple Pi sessions can control Chrome at once.
|
|
166
|
+
this.mode = "client";
|
|
167
|
+
}
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
stop(): void {
|
|
171
|
+
if (this.mode === "client") {
|
|
172
|
+
this.mode = undefined;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
159
175
|
for (const pending of this.pending.values()) {
|
|
160
176
|
clearTimeout(pending.timer);
|
|
161
177
|
pending.reject(new Error("Chrome profile bridge stopped"));
|
|
@@ -166,9 +182,15 @@ class ChromeProfileBridge {
|
|
|
166
182
|
this.waiters = [];
|
|
167
183
|
this.server?.close();
|
|
168
184
|
this.server = undefined;
|
|
185
|
+
this.mode = undefined;
|
|
169
186
|
}
|
|
170
187
|
|
|
171
188
|
send(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
|
189
|
+
if (this.mode === "client") return this.sendViaOwner(action, params, timeoutMs);
|
|
190
|
+
return this.sendLocal(action, params, timeoutMs);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private sendLocal(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
|
172
194
|
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
173
195
|
const command = { id, action, params };
|
|
174
196
|
return new Promise((resolveCommand, rejectCommand) => {
|
|
@@ -186,6 +208,34 @@ class ChromeProfileBridge {
|
|
|
186
208
|
});
|
|
187
209
|
}
|
|
188
210
|
|
|
211
|
+
private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
|
|
212
|
+
const controller = new AbortController();
|
|
213
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs + 2_000);
|
|
214
|
+
try {
|
|
215
|
+
const response = await fetch(`${this.url}/command`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "content-type": "application/json" },
|
|
218
|
+
body: JSON.stringify({ action, params, timeoutMs }),
|
|
219
|
+
signal: controller.signal,
|
|
220
|
+
});
|
|
221
|
+
const payload = (await response.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string };
|
|
222
|
+
if (response.status === 404) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
"A running Pi session owns the Chrome bridge but is using an older pi-chrome without multi-session support. Restart that Pi session after `pi update`, then retry.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (!response.ok || !payload.ok) throw new Error(payload.error ?? `Chrome bridge owner HTTP ${response.status}`);
|
|
228
|
+
return payload.result;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if ((error as Error).name === "AbortError") {
|
|
231
|
+
throw new Error(`Timed out waiting for shared Chrome bridge owner after ${timeoutMs}ms`);
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
234
|
+
} finally {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
189
239
|
private enqueue(command: BridgeCommand): void {
|
|
190
240
|
const waiter = this.waiters.shift();
|
|
191
241
|
if (waiter) waiter(command);
|
|
@@ -202,6 +252,24 @@ class ChromeProfileBridge {
|
|
|
202
252
|
sendJson(response, 200, this.status());
|
|
203
253
|
return;
|
|
204
254
|
}
|
|
255
|
+
if (request.method === "POST" && url.pathname === "/command") {
|
|
256
|
+
const body = JSON.parse(await readRequestBody(request)) as {
|
|
257
|
+
action?: string;
|
|
258
|
+
params?: Record<string, unknown>;
|
|
259
|
+
timeoutMs?: number;
|
|
260
|
+
};
|
|
261
|
+
if (!body.action) {
|
|
262
|
+
sendJson(response, 400, { ok: false, error: "Missing command action" });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const result = await this.sendLocal(body.action, body.params ?? {}, body.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
267
|
+
sendJson(response, 200, { ok: true, result });
|
|
268
|
+
} catch (error) {
|
|
269
|
+
sendJson(response, 504, { ok: false, error: (error as Error).message });
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
205
273
|
if (request.method === "GET" && url.pathname === "/next") {
|
|
206
274
|
this.lastSeenAt = Date.now();
|
|
207
275
|
this.clientName = url.searchParams.get("name") ?? undefined;
|
|
@@ -283,8 +351,8 @@ If chrome_* tools time out, ask the user to run /chrome-onboard, then load the b
|
|
|
283
351
|
: "Chrome profile bridge connected",
|
|
284
352
|
"info",
|
|
285
353
|
);
|
|
286
|
-
} catch {
|
|
287
|
-
ctx.ui.notify(
|
|
354
|
+
} catch (error) {
|
|
355
|
+
ctx.ui.notify(`Chrome bridge health check failed: ${(error as Error).message}`, "warning");
|
|
288
356
|
}
|
|
289
357
|
},
|
|
290
358
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-chrome",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Control your existing authenticated Chrome profile from Pi with tabs, snapshots, clicks, typing, JS evaluation, waits, and screenshots.",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Control your existing authenticated Chrome profile from one or more Pi sessions with tabs, snapshots, clicks, typing, JS evaluation, waits, and screenshots.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension",
|