@narumitw/pi-chrome-devtools 0.1.28 → 0.1.29

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
@@ -12,10 +12,12 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
12
12
 
13
13
  - Lists inspectable Chrome tabs and pages.
14
14
  - Selects an active Chrome page for later tool calls.
15
- - Navigates Chrome to a target URL.
15
+ - Navigates Chrome to a target URL, creating an inspectable page when none exists.
16
+ - Recovers from stale active page selections by falling back to an available page.
16
17
  - Evaluates JavaScript in the selected page.
17
18
  - Captures PNG screenshots, including optional full-page screenshots.
18
19
  - Uses a local Chrome DevTools Protocol endpoint.
20
+ - Retries briefly while Chrome is starting and reports actionable endpoint errors.
19
21
  - Shows statusline activity only while Chrome DevTools tools are running.
20
22
 
21
23
  ## 📦 Install
@@ -62,7 +64,7 @@ PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extension
62
64
 
63
65
  - `chrome_devtools_list_pages` — list inspectable Chrome tabs/pages.
64
66
  - `chrome_devtools_select_page` — select the active page for later tool calls.
65
- - `chrome_devtools_navigate` — navigate a page to a URL.
67
+ - `chrome_devtools_navigate` — navigate a page to a URL; if no page exists, create one first.
66
68
  - `chrome_devtools_evaluate` — evaluate JavaScript in the selected page.
67
69
  - `chrome_devtools_screenshot` — capture a PNG screenshot.
68
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-chrome-devtools",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Pi extension that exposes Chrome DevTools Protocol tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,6 +4,9 @@ import { Type } from "typebox";
4
4
  const DEFAULT_HOST = "127.0.0.1";
5
5
  const DEFAULT_PORT = 9222;
6
6
  const DEFAULT_TIMEOUT_MS = 10_000;
7
+ const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
8
+ const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
9
+ const DEFAULT_ENDPOINT_RETRY_MS = 250;
7
10
  const STATUS_KEY = "chrome-devtools";
8
11
 
9
12
  interface StatusContext {
@@ -75,8 +78,9 @@ const selectPageTool = defineTool({
75
78
  const navigateTool = defineTool({
76
79
  name: "chrome_devtools_navigate",
77
80
  label: "Chrome DevTools: Navigate",
78
- description: "Navigate a Chrome page to a URL through Chrome DevTools Protocol.",
79
- promptSnippet: "Navigate the selected Chrome tab to a URL",
81
+ description:
82
+ "Navigate a Chrome page to a URL through Chrome DevTools Protocol, creating a page first if none is available.",
83
+ promptSnippet: "Navigate the selected or first Chrome tab to a URL, creating one if needed",
80
84
  parameters: Type.Object({
81
85
  url: Type.String({ description: "URL to navigate to." }),
82
86
  pageId: Type.Optional(
@@ -85,14 +89,19 @@ const navigateTool = defineTool({
85
89
  }),
86
90
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
87
91
  return withStatus(ctx, "🌐 navigate", async () => {
88
- const page = await resolvePage(params.pageId);
92
+ const { created, page } = await resolvePageForNavigation(params.pageId);
89
93
  const result = await withCdp(page, async (client) => {
90
94
  await client.send("Page.enable");
91
95
  return client.send("Page.navigate", { url: params.url });
92
96
  });
93
97
 
94
98
  state.activePageId = page.id;
95
- return textResult(`Navigated ${page.id} to ${params.url}`, { page: formatPage(page), result });
99
+ const action = created ? "Created page and navigated" : "Navigated";
100
+ return textResult(`${action} ${page.id} to ${params.url}`, {
101
+ created,
102
+ page: formatPage(page),
103
+ result,
104
+ });
96
105
  });
97
106
  },
98
107
  });
@@ -190,10 +199,7 @@ export default function chromeDevtools(pi: ExtensionAPI) {
190
199
  pi.registerCommand("chrome-devtools", {
191
200
  description: "Show Chrome DevTools endpoint and quick start help",
192
201
  handler: async (_args, ctx) => {
193
- ctx.ui.notify(
194
- `Chrome DevTools endpoint: http://${state.host}:${state.port}. Start Chrome with --remote-debugging-port=${state.port}.`,
195
- "info",
196
- );
202
+ ctx.ui.notify(`Chrome DevTools endpoint: ${devToolsEndpoint()}\n${launchHint()}`, "info");
197
203
  },
198
204
  });
199
205
 
@@ -206,38 +212,183 @@ export default function chromeDevtools(pi: ExtensionAPI) {
206
212
  });
207
213
  }
208
214
 
209
- async function listPages() {
210
- const response = await fetch(`http://${state.host}:${state.port}/json/list`);
211
- if (!response.ok) {
212
- throw new Error(`Chrome DevTools endpoint returned ${response.status} ${response.statusText}`);
213
- }
214
-
215
- const pages = (await response.json()) as DevToolsPage[];
216
- return pages.filter((page) => page.type === "page" && page.webSocketDebuggerUrl);
215
+ async function listPages(options: { waitMs?: number } = {}) {
216
+ return withEndpointRetry(async () => {
217
+ const pages = await fetchDevToolsJson<DevToolsPage[]>("/json/list");
218
+ return pages.filter((page) => page.type === "page" && page.webSocketDebuggerUrl);
219
+ }, options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS);
217
220
  }
218
221
 
219
222
  async function getPage(pageId: string) {
220
223
  const pages = await listPages();
221
- const page = pages.find((candidate) => candidate.id === pageId);
222
- if (!page) throw new Error(`Chrome DevTools page not found: ${pageId}`);
223
- return page;
224
+ return requirePage(pageId, pages);
224
225
  }
225
226
 
226
227
  async function resolvePage(pageId?: string) {
227
- if (pageId) return getPage(pageId);
228
- if (state.activePageId) return getPage(state.activePageId);
229
-
230
228
  const pages = await listPages();
231
- const page = pages[0];
229
+ if (pageId) return requirePage(pageId, pages);
230
+
231
+ const page = resolveDefaultPage(pages);
232
232
  if (!page) {
233
233
  throw new Error(
234
- `No Chrome pages found at http://${state.host}:${state.port}. Start Chrome with --remote-debugging-port=${state.port}.`,
234
+ [
235
+ `No Chrome pages found at ${devToolsEndpoint()}.`,
236
+ "Use chrome_devtools_navigate with a URL to create a page, or open a Chrome tab manually.",
237
+ launchHint(),
238
+ ].join("\n"),
235
239
  );
236
240
  }
237
241
 
238
242
  return page;
239
243
  }
240
244
 
245
+ async function resolvePageForNavigation(pageId?: string) {
246
+ const pages = await listPages();
247
+ if (pageId) return { created: false, page: requirePage(pageId, pages) };
248
+
249
+ const page = resolveDefaultPage(pages);
250
+ if (page) return { created: false, page };
251
+
252
+ return { created: true, page: await createPage("about:blank") };
253
+ }
254
+
255
+ function resolveDefaultPage(pages: DevToolsPage[]) {
256
+ if (!state.activePageId) return pages[0];
257
+
258
+ const activePage = pages.find((candidate) => candidate.id === state.activePageId);
259
+ if (activePage) return activePage;
260
+
261
+ state.activePageId = undefined;
262
+ return pages[0];
263
+ }
264
+
265
+ function requirePage(pageId: string, pages: DevToolsPage[]) {
266
+ const page = pages.find((candidate) => candidate.id === pageId);
267
+ if (page) return page;
268
+
269
+ const availablePages = pages.map(formatPageListItem).join("\n");
270
+ throw new Error(
271
+ [
272
+ `Chrome DevTools page not found: ${pageId}.`,
273
+ availablePages
274
+ ? `Available pages:\n${availablePages}`
275
+ : "No inspectable Chrome pages are currently available.",
276
+ ].join("\n"),
277
+ );
278
+ }
279
+
280
+ async function createPage(url: string) {
281
+ const page = await fetchDevToolsJson<DevToolsPage>(`/json/new?${encodeURIComponent(url)}`, {
282
+ method: "PUT",
283
+ });
284
+ if (page.type !== "page" || !page.webSocketDebuggerUrl) {
285
+ throw new Error("Chrome DevTools created a target that is not an inspectable page.");
286
+ }
287
+
288
+ return page;
289
+ }
290
+
291
+ async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
292
+ const url = `${devToolsEndpoint()}${path}`;
293
+ let response: Response;
294
+ try {
295
+ response = await fetch(url, {
296
+ ...init,
297
+ signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
298
+ });
299
+ } catch (error) {
300
+ throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), { retryable: true });
301
+ }
302
+
303
+ if (!response.ok) {
304
+ const body = (await response.text().catch(() => "")).trim();
305
+ const suffix = body ? `: ${body.slice(0, 200)}` : "";
306
+ throw new DevToolsEndpointError(
307
+ [
308
+ `Chrome DevTools endpoint ${url} returned ${response.status} ${response.statusText}${suffix}.`,
309
+ endpointConfigHint(),
310
+ ].join("\n"),
311
+ { retryable: response.status === 429 || response.status >= 500 },
312
+ );
313
+ }
314
+
315
+ return (await response.json()) as T;
316
+ }
317
+
318
+ async function withEndpointRetry<T>(operation: () => Promise<T>, waitMs: number) {
319
+ const deadline = Date.now() + waitMs;
320
+ while (true) {
321
+ try {
322
+ return await operation();
323
+ } catch (error) {
324
+ if (!isRetryableEndpointError(error)) throw error;
325
+
326
+ const remainingMs = deadline - Date.now();
327
+ if (remainingMs <= 0) throw error;
328
+
329
+ await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
330
+ }
331
+ }
332
+ }
333
+
334
+ function isRetryableEndpointError(error: unknown) {
335
+ return error instanceof DevToolsEndpointError && error.retryable;
336
+ }
337
+
338
+ function endpointConnectionErrorMessage(error: unknown) {
339
+ const reason = isTimeoutError(error) ? "request timed out" : "connection failed";
340
+ return [
341
+ `Cannot connect to Chrome DevTools endpoint at ${devToolsEndpoint()} (${reason}).`,
342
+ launchHint(),
343
+ endpointConfigHint(),
344
+ ].join("\n");
345
+ }
346
+
347
+ function isTimeoutError(error: unknown) {
348
+ return error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name);
349
+ }
350
+
351
+ function devToolsEndpoint() {
352
+ return `http://${state.host}:${state.port}`;
353
+ }
354
+
355
+ function launchHint() {
356
+ return `Start Chrome with remote debugging enabled: ${chromeLaunchCommand()}`;
357
+ }
358
+
359
+ function chromeLaunchCommand() {
360
+ const executable =
361
+ process.platform === "darwin"
362
+ ? "/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome"
363
+ : process.platform === "win32"
364
+ ? "chrome.exe"
365
+ : "google-chrome";
366
+ const dataDir = process.platform === "win32" ? "%TEMP%\\pi-chrome-devtools" : "/tmp/pi-chrome-devtools";
367
+ return `${executable} --remote-debugging-port=${state.port} --user-data-dir=${dataDir}`;
368
+ }
369
+
370
+ function endpointConfigHint() {
371
+ return "Set PI_CHROME_DEVTOOLS_HOST and PI_CHROME_DEVTOOLS_PORT if Chrome uses a different endpoint.";
372
+ }
373
+
374
+ function formatPageListItem(page: DevToolsPage) {
375
+ return `- ${page.id}: ${page.title || "(untitled)"} ${page.url}`;
376
+ }
377
+
378
+ function sleep(ms: number) {
379
+ return new Promise((resolve) => setTimeout(resolve, ms));
380
+ }
381
+
382
+ class DevToolsEndpointError extends Error {
383
+ readonly retryable: boolean;
384
+
385
+ constructor(message: string, options: { retryable?: boolean } = {}) {
386
+ super(message);
387
+ this.name = "DevToolsEndpointError";
388
+ this.retryable = options.retryable ?? false;
389
+ }
390
+ }
391
+
241
392
  function formatPage(page: DevToolsPage) {
242
393
  return {
243
394
  id: page.id,
@@ -284,8 +435,10 @@ class CdpClient {
284
435
  timeout: NodeJS.Timeout;
285
436
  }
286
437
  >();
438
+ private readonly socket: WebSocket;
287
439
 
288
- private constructor(private readonly socket: WebSocket) {
440
+ private constructor(socket: WebSocket) {
441
+ this.socket = socket;
289
442
  socket.addEventListener("message", (event) => {
290
443
  const response = JSON.parse(String(event.data)) as CdpResponse;
291
444
  if (typeof response.id !== "number") return;