pi-chrome 0.1.2 → 0.2.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 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,29 @@ 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()) as { ok: boolean; result?: unknown; error?: string };
222
+ if (!response.ok || !payload.ok) throw new Error(payload.error ?? `Chrome bridge owner HTTP ${response.status}`);
223
+ return payload.result;
224
+ } catch (error) {
225
+ if ((error as Error).name === "AbortError") {
226
+ throw new Error(`Timed out waiting for shared Chrome bridge owner after ${timeoutMs}ms`);
227
+ }
228
+ throw error;
229
+ } finally {
230
+ clearTimeout(timer);
231
+ }
232
+ }
233
+
189
234
  private enqueue(command: BridgeCommand): void {
190
235
  const waiter = this.waiters.shift();
191
236
  if (waiter) waiter(command);
@@ -202,6 +247,24 @@ class ChromeProfileBridge {
202
247
  sendJson(response, 200, this.status());
203
248
  return;
204
249
  }
250
+ if (request.method === "POST" && url.pathname === "/command") {
251
+ const body = JSON.parse(await readRequestBody(request)) as {
252
+ action?: string;
253
+ params?: Record<string, unknown>;
254
+ timeoutMs?: number;
255
+ };
256
+ if (!body.action) {
257
+ sendJson(response, 400, { ok: false, error: "Missing command action" });
258
+ return;
259
+ }
260
+ try {
261
+ const result = await this.sendLocal(body.action, body.params ?? {}, body.timeoutMs ?? DEFAULT_TIMEOUT_MS);
262
+ sendJson(response, 200, { ok: true, result });
263
+ } catch (error) {
264
+ sendJson(response, 504, { ok: false, error: (error as Error).message });
265
+ }
266
+ return;
267
+ }
205
268
  if (request.method === "GET" && url.pathname === "/next") {
206
269
  this.lastSeenAt = Date.now();
207
270
  this.clientName = url.searchParams.get("name") ?? undefined;
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.0",
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",