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
- 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();
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("Chrome bridge health check timed out. Run /chrome-onboard to connect Chrome.", "warning");
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.2",
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",