rechrome 1.23.0 → 1.23.2

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.
@@ -129,6 +129,7 @@ class RelayConnection {
129
129
  __publicField(this, "_hasEverAttached", false);
130
130
  __publicField(this, "_eventListeners", []);
131
131
  __publicField(this, "_closed", false);
132
+ __publicField(this, "_keepAliveInterval");
132
133
  __publicField(this, "onclose");
133
134
  __publicField(this, "ontabattached");
134
135
  __publicField(this, "ontabdetached");
@@ -143,6 +144,9 @@ class RelayConnection {
143
144
  this._installEventForwarders();
144
145
  this._ws.onmessage = this._onMessage.bind(this);
145
146
  this._ws.onclose = () => this._onClose();
147
+ this._keepAliveInterval = setInterval(() => {
148
+ this._sendMessage({ method: "extension.keepalive", params: [] });
149
+ }, 2e4);
146
150
  }
147
151
  get attachedTabs() {
148
152
  return this._attachedTabs;
@@ -203,6 +207,7 @@ class RelayConnection {
203
207
  if (this._closed)
204
208
  return;
205
209
  this._closed = true;
210
+ clearInterval(this._keepAliveInterval);
206
211
  for (const l of this._eventListeners)
207
212
  l.remove();
208
213
  this._eventListeners = [];
@@ -352,18 +357,23 @@ class PendingConnections {
352
357
  }
353
358
  }
354
359
  async function openRelayConnection(mcpRelayUrl, protocolVersion) {
360
+ let socket;
361
+ let timer;
355
362
  try {
356
- const socket = new WebSocket(mcpRelayUrl);
363
+ socket = new WebSocket(mcpRelayUrl);
357
364
  await new Promise((resolve, reject) => {
358
365
  socket.onopen = () => resolve();
359
366
  socket.onerror = () => reject(new Error("WebSocket error"));
360
- setTimeout(() => reject(new Error("Connection timeout")), 5e3);
367
+ timer = setTimeout(() => reject(new Error("Connection timeout")), 5e3);
361
368
  });
362
369
  return new RelayConnection(socket, protocolVersion);
363
370
  } catch (error) {
371
+ socket == null ? void 0 : socket.close();
364
372
  const message = `Failed to connect to MCP relay: ${error.message}`;
365
373
  debugLog(message);
366
374
  throw new Error(message);
375
+ } finally {
376
+ clearTimeout(timer);
367
377
  }
368
378
  }
369
379
  const PLAYWRIGHT_GROUP_TITLE = "pw";
@@ -595,7 +605,10 @@ class PlaywrightExtension {
595
605
  }
596
606
  async _connectTab(selectorTabId, tab, clientName) {
597
607
  try {
598
- await this._cleanupPromise;
608
+ await Promise.race([
609
+ this._cleanupPromise,
610
+ new Promise((resolve) => setTimeout(resolve, 2e3))
611
+ ]);
599
612
  const connection = await this._pendingConnections.take(selectorTabId);
600
613
  if (!connection)
601
614
  throw new Error("Pending client connection closed");
@@ -1,5 +1,33 @@
1
1
  import { c as clientExports, j as jsxRuntimeExports, r as reactExports, A as AuthTokenSection, T as TabItem, B as Button, g as getOrCreateAuthToken } from "./authToken.js";
2
2
  const SUPPORTED_PROTOCOL_VERSION = 2;
3
+ const BACKGROUND_RESPONSE_TIMEOUT_MS = 1e4;
4
+ const SELF_RELOAD_GUARD_KEY = "connect-self-reload-at";
5
+ const SELF_RELOAD_MIN_INTERVAL_MS = 6e4;
6
+ function attemptExtensionSelfReload() {
7
+ if (new URLSearchParams(window.location.search).get("selfReload") !== "1")
8
+ return false;
9
+ const last = Number(localStorage.getItem(SELF_RELOAD_GUARD_KEY) || 0);
10
+ const now = Date.now();
11
+ if (now - last < SELF_RELOAD_MIN_INTERVAL_MS)
12
+ return false;
13
+ localStorage.setItem(SELF_RELOAD_GUARD_KEY, String(now));
14
+ chrome.runtime.reload();
15
+ return true;
16
+ }
17
+ async function sendMessageWithTimeout(message, simulateHang = false) {
18
+ let timer;
19
+ try {
20
+ return await Promise.race([
21
+ simulateHang ? new Promise(() => {
22
+ }) : chrome.runtime.sendMessage(message),
23
+ new Promise((_, reject) => {
24
+ timer = setTimeout(() => reject(new Error("Extension service worker did not respond")), BACKGROUND_RESPONSE_TIMEOUT_MS);
25
+ })
26
+ ]);
27
+ } finally {
28
+ clearTimeout(timer);
29
+ }
30
+ }
3
31
  const ConnectApp = () => {
4
32
  const [tabs, setTabs] = reactExports.useState([]);
5
33
  const [status, setStatus] = reactExports.useState(null);
@@ -13,6 +41,8 @@ const ConnectApp = () => {
13
41
  const runAsync = async () => {
14
42
  const params = new URLSearchParams(window.location.search);
15
43
  const relayUrl = params.get("mcpRelayUrl");
44
+ const recoveryAttempt = params.get("recoveryAttempt") === "1";
45
+ const hasAutomationToken = !!params.get("token");
16
46
  if (!relayUrl) {
17
47
  setError("Missing mcpRelayUrl parameter in URL.");
18
48
  return;
@@ -53,7 +83,21 @@ const ConnectApp = () => {
53
83
  });
54
84
  return;
55
85
  }
56
- const response = await chrome.runtime.sendMessage({ type: "connectionRequested", mcpRelayUrl: relayUrl, protocolVersion: requestedVersion });
86
+ let response;
87
+ try {
88
+ response = await sendMessageWithTimeout(
89
+ { type: "connectionRequested", mcpRelayUrl: relayUrl, protocolVersion: requestedVersion },
90
+ params.get("testHangOnce") === "1" && !recoveryAttempt
91
+ );
92
+ } catch (error) {
93
+ if (hasAutomationToken && !recoveryAttempt) {
94
+ const reloading = attemptExtensionSelfReload();
95
+ setError(reloading ? "Extension service worker is not responding. Reloading extension and retrying…" : "Extension service worker is not responding. Retrying once…");
96
+ return;
97
+ }
98
+ setError(`Extension service worker did not recover: ${error.message}`);
99
+ return;
100
+ }
57
101
  if (!response.success) {
58
102
  setError(response.error);
59
103
  return;
@@ -90,7 +134,7 @@ const ConnectApp = () => {
90
134
  const handleConnectToTab = reactExports.useCallback(async (tab, clientName = clientInfo) => {
91
135
  setShowTabList(false);
92
136
  try {
93
- const response = await chrome.runtime.sendMessage({
137
+ const response = await sendMessageWithTimeout({
94
138
  type: "connectToTab",
95
139
  tab,
96
140
  clientName
@@ -104,12 +148,19 @@ const ConnectApp = () => {
104
148
  });
105
149
  }
106
150
  } catch (e) {
151
+ const recoveryAttempt = new URLSearchParams(window.location.search).get("recoveryAttempt") === "1";
152
+ const hasAutomationToken = !!new URLSearchParams(window.location.search).get("token");
153
+ if (hasAutomationToken && !recoveryAttempt) {
154
+ const reloading = attemptExtensionSelfReload();
155
+ setError(reloading ? "Extension service worker stopped during connection. Reloading extension and retrying…" : "Extension service worker stopped during connection. Retrying once…");
156
+ return;
157
+ }
107
158
  setStatus({
108
159
  type: "error",
109
160
  message: `"${clientName}" failed to connect: ${e}`
110
161
  });
111
162
  }
112
- }, [clientInfo]);
163
+ }, [clientInfo, setError]);
113
164
  reactExports.useEffect(() => {
114
165
  const listener = (message) => {
115
166
  if (message.type === "pendingConnectionClosed") {
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Playwright Extension",
4
- "version": "0.2.1",
4
+ "version": "0.2.2",
5
5
  "description": "Connect your browser to AI agents through Playwright MCP server and CLI. Enables AI-driven web testing, debugging, and automation.",
6
+ "minimum_chrome_version": "116",
6
7
  "permissions": [
7
8
  "debugger",
8
9
  "activeTab",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.23.0",
3
+ "version": "1.23.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -351,7 +351,9 @@ const CHROME_LOCAL_STATE_PATHS = () => {
351
351
  ];
352
352
  };
353
353
 
354
- async function readChromeProfileCache(): Promise<Record<string, { user_name?: string; name?: string }> | null> {
354
+ type ChromeProfileInfo = { user_name?: string; name?: string };
355
+
356
+ async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
355
357
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
356
358
  const f = file(statePath);
357
359
  if (!(await f.exists())) continue;
@@ -363,6 +365,41 @@ async function readChromeProfileCache(): Promise<Record<string, { user_name?: st
363
365
  return null;
364
366
  }
365
367
 
368
+ export function resolveChromeProfileSelector(
369
+ profiles: Array<[string, ChromeProfileInfo]>,
370
+ selector: string,
371
+ ): [string, ChromeProfileInfo] | null {
372
+ const value = selector.trim();
373
+ validateChromeProfileSelector(value);
374
+
375
+ const needle = value.toLowerCase();
376
+ const selectors: Array<{ label: string; value: (dir: string, info: ChromeProfileInfo) => string }> = [
377
+ { label: "email", value: (_dir, info) => info.user_name ?? "" },
378
+ { label: "Chrome profile name", value: (_dir, info) => info.name ?? "" },
379
+ { label: "profile folder name", value: (dir) => dir },
380
+ ];
381
+ for (const kind of selectors) {
382
+ const matches = profiles.filter(([dir, info]) => kind.value(dir, info).trim().toLowerCase() === needle);
383
+ if (matches.length > 1) {
384
+ throw new Error(
385
+ `--profile "${value}" matches multiple profiles by ${kind.label}. ` +
386
+ `Use a unique email or profile folder name from \`rech profiles\`.`,
387
+ );
388
+ }
389
+ if (matches.length === 1) return matches[0];
390
+ }
391
+ return null;
392
+ }
393
+
394
+ export function validateChromeProfileSelector(selector: string): void {
395
+ const value = selector.trim();
396
+ if (!/^\d+$/.test(value)) return;
397
+ throw new Error(
398
+ `--profile no longer accepts menu numbers (received "${value}"). ` +
399
+ `Use the profile email, Chrome profile name, or profile folder name from \`rech profiles\`.`,
400
+ );
401
+ }
402
+
366
403
  async function findChromeUserDataDir(): Promise<string | null> {
367
404
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
368
405
  if (!(await file(statePath).exists())) continue;
@@ -480,15 +517,14 @@ async function listProfiles(): Promise<void> {
480
517
  }
481
518
  }
482
519
 
483
- // Header clarifies each column; email/nickname sit beside the profile dir so a bare
484
- // "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
520
+ // Columns mirror selector precedence. Only user_name (email) + name (profile name) are read
485
521
  // from Local State — the gaia real name is deliberately never surfaced.
486
522
  const rows = [
487
- ["PROFILE", "EMAIL", "NICKNAME", ""],
523
+ ["EMAIL", "PROFILE NAME", "FOLDER", ""],
488
524
  ...Object.entries(cache).map(([dir, info]) => [
489
- dir,
490
525
  info.user_name || "",
491
526
  info.name || "",
527
+ dir,
492
528
  dir === currentDir ? "← current" : "",
493
529
  ]),
494
530
  ];
@@ -546,7 +582,16 @@ async function callServe(
546
582
  return res.json();
547
583
  }
548
584
 
585
+ export function normalizeCommandArgs(args: string[]): string[] {
586
+ const normalized = [...args];
587
+ if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
588
+ return normalized;
589
+ }
590
+
549
591
  async function run(url: string, args: string[]) {
592
+ // Match the underlying CLI's command names while accepting the short forms humans
593
+ // naturally try. Keep this client-side so old and new serve daemons behave alike.
594
+ args = normalizeCommandArgs(args);
550
595
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
551
596
  const effectiveProfile = resolveEffectiveProfile(profileDirectory);
552
597
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
@@ -570,10 +615,17 @@ async function run(url: string, args: string[]) {
570
615
  if (stderr.includes('Extension connection timeout')) {
571
616
  const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
572
617
  const last = hasToken
573
- ? ` -x: extension token rejected -> extension[unknown]`
618
+ ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
574
619
  : ` -> extension[not installed] (run: rech setup)`;
575
620
  console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
576
621
  }
622
+ if (stderr.includes("Browser '") && stderr.includes("is not open")) {
623
+ console.error(
624
+ `[rech] the session id is derived from this worktree and profile; it is not a persisted stale id. ` +
625
+ `The preceding open did not finish. Retry \`rech open <url>\`; if it reports an extension timeout, ` +
626
+ `reload Playwright MCP Bridge at chrome://extensions and retry.`,
627
+ );
628
+ }
577
629
  process.stderr.write(stderr);
578
630
  }
579
631
  if (stdout) process.stdout.write(stdout);
@@ -1136,6 +1188,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1136
1188
  }
1137
1189
 
1138
1190
  async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
1191
+ if (opts.profile !== undefined) {
1192
+ try {
1193
+ validateChromeProfileSelector(opts.profile);
1194
+ } catch (error) {
1195
+ console.error(error instanceof Error ? error.message : String(error));
1196
+ envWatcher?.close();
1197
+ process.exit(1);
1198
+ }
1199
+ }
1139
1200
  const { createInterface } = await import("readline");
1140
1201
  const isTTY = process.stdin.isTTY ?? false;
1141
1202
  let rl: ReturnType<typeof createInterface> | null = null;
@@ -1157,8 +1218,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1157
1218
  return new Promise<string>(r => rl!.question("", ans => r(ans || def)));
1158
1219
  };
1159
1220
 
1160
- // [1/4] Daemon
1161
- console.log("\n[1/4] Checking serve daemon...");
1221
+ // [1/5] Daemon
1222
+ console.log("\n[1/5] Checking serve daemon...");
1162
1223
 
1163
1224
  // Bind address (persists to ~/.env.local as RECH_HOST).
1164
1225
  // Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
@@ -1262,40 +1323,20 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1262
1323
  if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
1263
1324
  const userDataDir = await findChromeUserDataDir();
1264
1325
 
1265
- async function pickProfile(exclude: Set<string>): Promise<[string, { user_name?: string; name?: string }] | null> {
1326
+ async function pickProfile(exclude: Set<string>): Promise<[string, ChromeProfileInfo] | null> {
1266
1327
  const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
1267
1328
  if (!available.length) return null;
1268
1329
  available.forEach(([dir, info], i) =>
1269
1330
  console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
1270
1331
  );
1271
1332
  if (opts.profile !== undefined) {
1272
- const num = parseInt(opts.profile);
1273
- if (!isNaN(num) && String(num) === opts.profile.trim()) {
1274
- // A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
1275
- // "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
1276
- // *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
1277
- // and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
1278
- // selection either way. Email is the unambiguous selector — steer toward it.
1279
- const sel = available[num - 1] ?? null;
1280
- const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
1281
- if (dirNamed && dirNamed[0] !== sel?.[0]) {
1282
- const hint = dirNamed[1].user_name || `"Profile ${num}"`;
1283
- console.error(
1284
- ` [warn] --profile ${num} = menu index ${num} → ` +
1285
- `${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
1286
- `NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
1287
- `For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
1288
- );
1289
- }
1290
- if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
1291
- return sel;
1333
+ let match: [string, ChromeProfileInfo] | null;
1334
+ try {
1335
+ match = resolveChromeProfileSelector(available, opts.profile);
1336
+ } catch (error) {
1337
+ console.error(` ${error instanceof Error ? error.message : String(error)}`);
1338
+ return null;
1292
1339
  }
1293
- const needle = opts.profile.toLowerCase();
1294
- const match = available.find(([dir, info]) =>
1295
- dir.toLowerCase() === needle
1296
- || (info.name ?? "").toLowerCase() === needle
1297
- || (info.user_name ?? "").toLowerCase().includes(needle)
1298
- ) ?? null;
1299
1340
  if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
1300
1341
  return match;
1301
1342
  }
@@ -1303,7 +1344,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1303
1344
  console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
1304
1345
  return available[0];
1305
1346
  }
1306
- if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <num|email>");
1347
+ if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <email|name|folder>");
1307
1348
  const answer = await ask("\n Profile number: ");
1308
1349
  const idx = parseInt(answer.trim()) - 1;
1309
1350
  if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
@@ -1334,7 +1375,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1334
1375
  console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
1335
1376
  console.error(` ${EXTENSION_DIST_DIR}`);
1336
1377
  console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
1337
- console.error(` Then re-run: rech setup --profile <num|email> [--token <tok>]`);
1378
+ console.error(` Then re-run: rech setup --profile <email|name|folder> [--token <tok>]`);
1338
1379
  return null;
1339
1380
  }
1340
1381
  await ask("\n Press Enter after loading the extension to retry...");
@@ -1420,21 +1461,22 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1420
1461
  return null;
1421
1462
  }
1422
1463
 
1423
- // [2/4] Primary profile
1424
- console.log("\n[2/4] Select Chrome profile:");
1464
+ // [2/5] Primary profile
1465
+ console.log("\n[2/5] Select Chrome profile:");
1425
1466
  const picked = await pickProfile(new Set());
1426
1467
  if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
1427
1468
  const [profileDir, profileInfoSel] = picked;
1428
1469
  const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
1429
1470
 
1430
- // [3+4/4] Extension + token for primary profile
1431
- console.log("\n[3/4] Checking extension...");
1471
+ // [3/5] Extension + token for primary profile
1472
+ console.log("\n[3/5] Checking extension...");
1432
1473
  const profileEmail = profileInfoSel.user_name || profileDir;
1433
1474
  const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
1434
1475
  if (!primary) { rl?.close(); process.exit(1); }
1435
1476
  const { extId, token } = primary;
1436
1477
 
1437
- // Build RECHROME_URL and show it before asking where to save
1478
+ // Build RECHROME_URL, verify the selected profile can complete a real extension
1479
+ // handshake, then show it before asking where to save.
1438
1480
  const rechUrl = new URL(url);
1439
1481
  if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
1440
1482
  rechUrl.searchParams.set("extension_id", extId);
@@ -1442,7 +1484,39 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1442
1484
  rechUrl.searchParams.set("profile", profileEmail);
1443
1485
  if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
1444
1486
  const newLine = `RECHROME_URL=${rechUrl.toString()}`;
1445
- console.log(`\n[4/4] Your RECHROME_URL:\n\n ${newLine}\n`);
1487
+ console.log(`\n[4/5] Verifying extension bridge for ${profileDisplay}...`);
1488
+ const probeSession = `iso-setup-${randomBytes(4).toString("hex")}`;
1489
+ const probeIdentity = await getClientIdentity();
1490
+ probeIdentity.profile = profileEmail;
1491
+ const probeEnv: Record<string, string> = {
1492
+ PLAYWRIGHT_MCP_EXTENSION_ID: extId,
1493
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN: token,
1494
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileEmail,
1495
+ ...(userDataDir ? { PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir } : {}),
1496
+ };
1497
+ let bridgeVerified = false;
1498
+ try {
1499
+ const probe = await callServe(
1500
+ rechUrl.toString(),
1501
+ [`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
1502
+ probeEnv,
1503
+ probeIdentity,
1504
+ );
1505
+ bridgeVerified = probe.status === 0;
1506
+ if (bridgeVerified) {
1507
+ console.log(" Extension bridge connected successfully");
1508
+ } else {
1509
+ const diagnostic = probe.stderr.trim() || probe.stdout.trim() || `bridge probe exited ${probe.status}`;
1510
+ console.error(` Extension bridge verification failed: ${diagnostic}`);
1511
+ }
1512
+ } catch (error) {
1513
+ console.error(` Extension bridge verification failed: ${error instanceof Error ? error.message : String(error)}`);
1514
+ } finally {
1515
+ // The isolated probe must never claim or close an existing worktree session.
1516
+ await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity).catch(() => {});
1517
+ }
1518
+
1519
+ console.log(`\n[5/5] Your RECHROME_URL:\n\n ${newLine}\n`);
1446
1520
  if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
1447
1521
 
1448
1522
  const pwdEnvPath = join(process.cwd(), ".env.local");
@@ -1493,7 +1567,10 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1493
1567
  }
1494
1568
  rl?.close();
1495
1569
  envWatcher?.close();
1496
- console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
1570
+ if (bridgeVerified)
1571
+ console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
1572
+ else
1573
+ console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
1497
1574
  }
1498
1575
 
1499
1576
  async function status(): Promise<void> {
@@ -1542,14 +1619,13 @@ function printHelp(): void {
1542
1619
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1543
1620
 
1544
1621
  Usage:
1545
- rech setup [--profile <num|email>] [--token <tok>]
1622
+ rech setup [--profile <email|name|folder>] [--token <tok>]
1546
1623
  First-time setup: daemon + Chrome extension + config
1547
1624
  --profile selects the Chrome profile non-interactively.
1548
- A bare number is the 1-based MENU INDEX (position in the
1549
- listed order), NOT the Chrome directory "Profile N". For
1550
- scripts prefer the email (e.g. --profile you@gmail.com)
1551
- it is the only unambiguous selector; an exact directory
1552
- name ("Profile 1") also matches.
1625
+ Menu numbers are not accepted. Resolution order is exact
1626
+ email (e.g. you@gmail.com), exact Chrome profile name,
1627
+ then exact profile folder name (e.g. "Profile 1"). See
1628
+ available values with \`rech profiles\`.
1553
1629
  --token (or RECH_TOKEN) supplies the auth token for
1554
1630
  non-TTY/agent runs, skipping the interactive paste
1555
1631
  rech provision-profile <name> --experimental [--headed]
@@ -1578,7 +1654,7 @@ Environment:
1578
1654
 
1579
1655
  Examples:
1580
1656
  rech setup
1581
- rech setup --profile 18 --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1657
+ rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1582
1658
  rech eval "() => document.title"
1583
1659
  rech open https://example.com
1584
1660
  rech screenshot`);
@@ -1624,7 +1700,7 @@ if (import.meta.main) {
1624
1700
  if (!experimental) {
1625
1701
  console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
1626
1702
  console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
1627
- console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <N>`);
1703
+ console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`);
1628
1704
  console.error(`To proceed anyway, re-run with --experimental.`);
1629
1705
  process.exit(1);
1630
1706
  }
package/rech.ts CHANGED
@@ -351,7 +351,9 @@ const CHROME_LOCAL_STATE_PATHS = () => {
351
351
  ];
352
352
  };
353
353
 
354
- async function readChromeProfileCache(): Promise<Record<string, { user_name?: string; name?: string }> | null> {
354
+ type ChromeProfileInfo = { user_name?: string; name?: string };
355
+
356
+ async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
355
357
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
356
358
  const f = file(statePath);
357
359
  if (!(await f.exists())) continue;
@@ -363,6 +365,41 @@ async function readChromeProfileCache(): Promise<Record<string, { user_name?: st
363
365
  return null;
364
366
  }
365
367
 
368
+ export function resolveChromeProfileSelector(
369
+ profiles: Array<[string, ChromeProfileInfo]>,
370
+ selector: string,
371
+ ): [string, ChromeProfileInfo] | null {
372
+ const value = selector.trim();
373
+ validateChromeProfileSelector(value);
374
+
375
+ const needle = value.toLowerCase();
376
+ const selectors: Array<{ label: string; value: (dir: string, info: ChromeProfileInfo) => string }> = [
377
+ { label: "email", value: (_dir, info) => info.user_name ?? "" },
378
+ { label: "Chrome profile name", value: (_dir, info) => info.name ?? "" },
379
+ { label: "profile folder name", value: (dir) => dir },
380
+ ];
381
+ for (const kind of selectors) {
382
+ const matches = profiles.filter(([dir, info]) => kind.value(dir, info).trim().toLowerCase() === needle);
383
+ if (matches.length > 1) {
384
+ throw new Error(
385
+ `--profile "${value}" matches multiple profiles by ${kind.label}. ` +
386
+ `Use a unique email or profile folder name from \`rech profiles\`.`,
387
+ );
388
+ }
389
+ if (matches.length === 1) return matches[0];
390
+ }
391
+ return null;
392
+ }
393
+
394
+ export function validateChromeProfileSelector(selector: string): void {
395
+ const value = selector.trim();
396
+ if (!/^\d+$/.test(value)) return;
397
+ throw new Error(
398
+ `--profile no longer accepts menu numbers (received "${value}"). ` +
399
+ `Use the profile email, Chrome profile name, or profile folder name from \`rech profiles\`.`,
400
+ );
401
+ }
402
+
366
403
  async function findChromeUserDataDir(): Promise<string | null> {
367
404
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
368
405
  if (!(await file(statePath).exists())) continue;
@@ -480,15 +517,14 @@ async function listProfiles(): Promise<void> {
480
517
  }
481
518
  }
482
519
 
483
- // Header clarifies each column; email/nickname sit beside the profile dir so a bare
484
- // "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
520
+ // Columns mirror selector precedence. Only user_name (email) + name (profile name) are read
485
521
  // from Local State — the gaia real name is deliberately never surfaced.
486
522
  const rows = [
487
- ["PROFILE", "EMAIL", "NICKNAME", ""],
523
+ ["EMAIL", "PROFILE NAME", "FOLDER", ""],
488
524
  ...Object.entries(cache).map(([dir, info]) => [
489
- dir,
490
525
  info.user_name || "",
491
526
  info.name || "",
527
+ dir,
492
528
  dir === currentDir ? "← current" : "",
493
529
  ]),
494
530
  ];
@@ -546,7 +582,16 @@ async function callServe(
546
582
  return res.json();
547
583
  }
548
584
 
585
+ export function normalizeCommandArgs(args: string[]): string[] {
586
+ const normalized = [...args];
587
+ if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
588
+ return normalized;
589
+ }
590
+
549
591
  async function run(url: string, args: string[]) {
592
+ // Match the underlying CLI's command names while accepting the short forms humans
593
+ // naturally try. Keep this client-side so old and new serve daemons behave alike.
594
+ args = normalizeCommandArgs(args);
550
595
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
551
596
  const effectiveProfile = resolveEffectiveProfile(profileDirectory);
552
597
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
@@ -570,10 +615,17 @@ async function run(url: string, args: string[]) {
570
615
  if (stderr.includes('Extension connection timeout')) {
571
616
  const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
572
617
  const last = hasToken
573
- ? ` -x: extension token rejected -> extension[unknown]`
618
+ ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
574
619
  : ` -> extension[not installed] (run: rech setup)`;
575
620
  console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
576
621
  }
622
+ if (stderr.includes("Browser '") && stderr.includes("is not open")) {
623
+ console.error(
624
+ `[rech] the session id is derived from this worktree and profile; it is not a persisted stale id. ` +
625
+ `The preceding open did not finish. Retry \`rech open <url>\`; if it reports an extension timeout, ` +
626
+ `reload Playwright MCP Bridge at chrome://extensions and retry.`,
627
+ );
628
+ }
577
629
  process.stderr.write(stderr);
578
630
  }
579
631
  if (stdout) process.stdout.write(stdout);
@@ -1136,6 +1188,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1136
1188
  }
1137
1189
 
1138
1190
  async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
1191
+ if (opts.profile !== undefined) {
1192
+ try {
1193
+ validateChromeProfileSelector(opts.profile);
1194
+ } catch (error) {
1195
+ console.error(error instanceof Error ? error.message : String(error));
1196
+ envWatcher?.close();
1197
+ process.exit(1);
1198
+ }
1199
+ }
1139
1200
  const { createInterface } = await import("readline");
1140
1201
  const isTTY = process.stdin.isTTY ?? false;
1141
1202
  let rl: ReturnType<typeof createInterface> | null = null;
@@ -1157,8 +1218,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1157
1218
  return new Promise<string>(r => rl!.question("", ans => r(ans || def)));
1158
1219
  };
1159
1220
 
1160
- // [1/4] Daemon
1161
- console.log("\n[1/4] Checking serve daemon...");
1221
+ // [1/5] Daemon
1222
+ console.log("\n[1/5] Checking serve daemon...");
1162
1223
 
1163
1224
  // Bind address (persists to ~/.env.local as RECH_HOST).
1164
1225
  // Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
@@ -1262,40 +1323,20 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1262
1323
  if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
1263
1324
  const userDataDir = await findChromeUserDataDir();
1264
1325
 
1265
- async function pickProfile(exclude: Set<string>): Promise<[string, { user_name?: string; name?: string }] | null> {
1326
+ async function pickProfile(exclude: Set<string>): Promise<[string, ChromeProfileInfo] | null> {
1266
1327
  const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
1267
1328
  if (!available.length) return null;
1268
1329
  available.forEach(([dir, info], i) =>
1269
1330
  console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
1270
1331
  );
1271
1332
  if (opts.profile !== undefined) {
1272
- const num = parseInt(opts.profile);
1273
- if (!isNaN(num) && String(num) === opts.profile.trim()) {
1274
- // A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
1275
- // "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
1276
- // *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
1277
- // and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
1278
- // selection either way. Email is the unambiguous selector — steer toward it.
1279
- const sel = available[num - 1] ?? null;
1280
- const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
1281
- if (dirNamed && dirNamed[0] !== sel?.[0]) {
1282
- const hint = dirNamed[1].user_name || `"Profile ${num}"`;
1283
- console.error(
1284
- ` [warn] --profile ${num} = menu index ${num} → ` +
1285
- `${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
1286
- `NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
1287
- `For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
1288
- );
1289
- }
1290
- if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
1291
- return sel;
1333
+ let match: [string, ChromeProfileInfo] | null;
1334
+ try {
1335
+ match = resolveChromeProfileSelector(available, opts.profile);
1336
+ } catch (error) {
1337
+ console.error(` ${error instanceof Error ? error.message : String(error)}`);
1338
+ return null;
1292
1339
  }
1293
- const needle = opts.profile.toLowerCase();
1294
- const match = available.find(([dir, info]) =>
1295
- dir.toLowerCase() === needle
1296
- || (info.name ?? "").toLowerCase() === needle
1297
- || (info.user_name ?? "").toLowerCase().includes(needle)
1298
- ) ?? null;
1299
1340
  if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
1300
1341
  return match;
1301
1342
  }
@@ -1303,7 +1344,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1303
1344
  console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
1304
1345
  return available[0];
1305
1346
  }
1306
- if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <num|email>");
1347
+ if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <email|name|folder>");
1307
1348
  const answer = await ask("\n Profile number: ");
1308
1349
  const idx = parseInt(answer.trim()) - 1;
1309
1350
  if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
@@ -1334,7 +1375,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1334
1375
  console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
1335
1376
  console.error(` ${EXTENSION_DIST_DIR}`);
1336
1377
  console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
1337
- console.error(` Then re-run: rech setup --profile <num|email> [--token <tok>]`);
1378
+ console.error(` Then re-run: rech setup --profile <email|name|folder> [--token <tok>]`);
1338
1379
  return null;
1339
1380
  }
1340
1381
  await ask("\n Press Enter after loading the extension to retry...");
@@ -1420,21 +1461,22 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1420
1461
  return null;
1421
1462
  }
1422
1463
 
1423
- // [2/4] Primary profile
1424
- console.log("\n[2/4] Select Chrome profile:");
1464
+ // [2/5] Primary profile
1465
+ console.log("\n[2/5] Select Chrome profile:");
1425
1466
  const picked = await pickProfile(new Set());
1426
1467
  if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
1427
1468
  const [profileDir, profileInfoSel] = picked;
1428
1469
  const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
1429
1470
 
1430
- // [3+4/4] Extension + token for primary profile
1431
- console.log("\n[3/4] Checking extension...");
1471
+ // [3/5] Extension + token for primary profile
1472
+ console.log("\n[3/5] Checking extension...");
1432
1473
  const profileEmail = profileInfoSel.user_name || profileDir;
1433
1474
  const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
1434
1475
  if (!primary) { rl?.close(); process.exit(1); }
1435
1476
  const { extId, token } = primary;
1436
1477
 
1437
- // Build RECHROME_URL and show it before asking where to save
1478
+ // Build RECHROME_URL, verify the selected profile can complete a real extension
1479
+ // handshake, then show it before asking where to save.
1438
1480
  const rechUrl = new URL(url);
1439
1481
  if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
1440
1482
  rechUrl.searchParams.set("extension_id", extId);
@@ -1442,7 +1484,39 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1442
1484
  rechUrl.searchParams.set("profile", profileEmail);
1443
1485
  if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
1444
1486
  const newLine = `RECHROME_URL=${rechUrl.toString()}`;
1445
- console.log(`\n[4/4] Your RECHROME_URL:\n\n ${newLine}\n`);
1487
+ console.log(`\n[4/5] Verifying extension bridge for ${profileDisplay}...`);
1488
+ const probeSession = `iso-setup-${randomBytes(4).toString("hex")}`;
1489
+ const probeIdentity = await getClientIdentity();
1490
+ probeIdentity.profile = profileEmail;
1491
+ const probeEnv: Record<string, string> = {
1492
+ PLAYWRIGHT_MCP_EXTENSION_ID: extId,
1493
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN: token,
1494
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileEmail,
1495
+ ...(userDataDir ? { PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir } : {}),
1496
+ };
1497
+ let bridgeVerified = false;
1498
+ try {
1499
+ const probe = await callServe(
1500
+ rechUrl.toString(),
1501
+ [`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
1502
+ probeEnv,
1503
+ probeIdentity,
1504
+ );
1505
+ bridgeVerified = probe.status === 0;
1506
+ if (bridgeVerified) {
1507
+ console.log(" Extension bridge connected successfully");
1508
+ } else {
1509
+ const diagnostic = probe.stderr.trim() || probe.stdout.trim() || `bridge probe exited ${probe.status}`;
1510
+ console.error(` Extension bridge verification failed: ${diagnostic}`);
1511
+ }
1512
+ } catch (error) {
1513
+ console.error(` Extension bridge verification failed: ${error instanceof Error ? error.message : String(error)}`);
1514
+ } finally {
1515
+ // The isolated probe must never claim or close an existing worktree session.
1516
+ await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity).catch(() => {});
1517
+ }
1518
+
1519
+ console.log(`\n[5/5] Your RECHROME_URL:\n\n ${newLine}\n`);
1446
1520
  if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
1447
1521
 
1448
1522
  const pwdEnvPath = join(process.cwd(), ".env.local");
@@ -1493,7 +1567,10 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1493
1567
  }
1494
1568
  rl?.close();
1495
1569
  envWatcher?.close();
1496
- console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
1570
+ if (bridgeVerified)
1571
+ console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
1572
+ else
1573
+ console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
1497
1574
  }
1498
1575
 
1499
1576
  async function status(): Promise<void> {
@@ -1542,14 +1619,13 @@ function printHelp(): void {
1542
1619
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1543
1620
 
1544
1621
  Usage:
1545
- rech setup [--profile <num|email>] [--token <tok>]
1622
+ rech setup [--profile <email|name|folder>] [--token <tok>]
1546
1623
  First-time setup: daemon + Chrome extension + config
1547
1624
  --profile selects the Chrome profile non-interactively.
1548
- A bare number is the 1-based MENU INDEX (position in the
1549
- listed order), NOT the Chrome directory "Profile N". For
1550
- scripts prefer the email (e.g. --profile you@gmail.com)
1551
- it is the only unambiguous selector; an exact directory
1552
- name ("Profile 1") also matches.
1625
+ Menu numbers are not accepted. Resolution order is exact
1626
+ email (e.g. you@gmail.com), exact Chrome profile name,
1627
+ then exact profile folder name (e.g. "Profile 1"). See
1628
+ available values with \`rech profiles\`.
1553
1629
  --token (or RECH_TOKEN) supplies the auth token for
1554
1630
  non-TTY/agent runs, skipping the interactive paste
1555
1631
  rech provision-profile <name> --experimental [--headed]
@@ -1578,7 +1654,7 @@ Environment:
1578
1654
 
1579
1655
  Examples:
1580
1656
  rech setup
1581
- rech setup --profile 18 --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1657
+ rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1582
1658
  rech eval "() => document.title"
1583
1659
  rech open https://example.com
1584
1660
  rech screenshot`);
@@ -1624,7 +1700,7 @@ if (import.meta.main) {
1624
1700
  if (!experimental) {
1625
1701
  console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
1626
1702
  console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
1627
- console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <N>`);
1703
+ console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`);
1628
1704
  console.error(`To proceed anyway, re-run with --experimental.`);
1629
1705
  process.exit(1);
1630
1706
  }
package/serve.js CHANGED
@@ -84,6 +84,23 @@ function noteSession(sess: string, now: number): void {
84
84
  }
85
85
  }
86
86
 
87
+ export function inferSilentExtensionFailure(options: {
88
+ status: number;
89
+ stdout: string;
90
+ stderr: string;
91
+ isOpenCommand: boolean;
92
+ hasExtensionCredentials: boolean;
93
+ elapsedMs: number;
94
+ handshakeTimeoutMs: number;
95
+ }): string {
96
+ const { status, stdout, stderr, isOpenCommand, hasExtensionCredentials, elapsedMs, handshakeTimeoutMs } = options;
97
+ if (stderr || status === 0 || stdout.trim() || !isOpenCommand || !hasExtensionCredentials)
98
+ return stderr;
99
+ if (elapsedMs < Math.max(1_000, handshakeTimeoutMs - 1_000))
100
+ return stderr;
101
+ return `Extension connection timeout after ${handshakeTimeoutMs}ms. Automatic recovery retry failed; reload the Playwright MCP Bridge extension at chrome://extensions and retry.\n`;
102
+ }
103
+
87
104
  function tmpSocketRoot(): string {
88
105
  return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
89
106
  }
@@ -478,6 +495,7 @@ export async function serve() {
478
495
  } catch {}
479
496
  }
480
497
 
498
+ const commandStartedAt = Date.now();
481
499
  const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
482
500
  cwd: workDir,
483
501
  stdin: "ignore",
@@ -497,7 +515,7 @@ export async function serve() {
497
515
  reject(new Error("timeout"));
498
516
  }, TIMEOUT);
499
517
  });
500
- const [status, stdout, stderr] = await Promise.race([
518
+ const [status, stdout, rawStderr] = await Promise.race([
501
519
  Promise.all([
502
520
  proc.exited,
503
521
  new Response(proc.stdout).text(),
@@ -509,6 +527,20 @@ export async function serve() {
509
527
  ) as [number, string, string];
510
528
  clearTimeout(timer);
511
529
 
530
+ const configuredHandshakeTimeout = Number(childEnv.PWMCP_TEST_CONNECTION_TIMEOUT);
531
+ const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
532
+ ? configuredHandshakeTimeout
533
+ : 30_000;
534
+ const stderr = inferSilentExtensionFailure({
535
+ status,
536
+ stdout,
537
+ stderr: rawStderr,
538
+ isOpenCommand: isOpenCmd,
539
+ hasExtensionCredentials: !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN),
540
+ elapsedMs: Date.now() - commandStartedAt,
541
+ handshakeTimeoutMs,
542
+ });
543
+
512
544
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
513
545
 
514
546
  // Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
package/serve.ts CHANGED
@@ -84,6 +84,23 @@ function noteSession(sess: string, now: number): void {
84
84
  }
85
85
  }
86
86
 
87
+ export function inferSilentExtensionFailure(options: {
88
+ status: number;
89
+ stdout: string;
90
+ stderr: string;
91
+ isOpenCommand: boolean;
92
+ hasExtensionCredentials: boolean;
93
+ elapsedMs: number;
94
+ handshakeTimeoutMs: number;
95
+ }): string {
96
+ const { status, stdout, stderr, isOpenCommand, hasExtensionCredentials, elapsedMs, handshakeTimeoutMs } = options;
97
+ if (stderr || status === 0 || stdout.trim() || !isOpenCommand || !hasExtensionCredentials)
98
+ return stderr;
99
+ if (elapsedMs < Math.max(1_000, handshakeTimeoutMs - 1_000))
100
+ return stderr;
101
+ return `Extension connection timeout after ${handshakeTimeoutMs}ms. Automatic recovery retry failed; reload the Playwright MCP Bridge extension at chrome://extensions and retry.\n`;
102
+ }
103
+
87
104
  function tmpSocketRoot(): string {
88
105
  return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
89
106
  }
@@ -478,6 +495,7 @@ export async function serve() {
478
495
  } catch {}
479
496
  }
480
497
 
498
+ const commandStartedAt = Date.now();
481
499
  const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
482
500
  cwd: workDir,
483
501
  stdin: "ignore",
@@ -497,7 +515,7 @@ export async function serve() {
497
515
  reject(new Error("timeout"));
498
516
  }, TIMEOUT);
499
517
  });
500
- const [status, stdout, stderr] = await Promise.race([
518
+ const [status, stdout, rawStderr] = await Promise.race([
501
519
  Promise.all([
502
520
  proc.exited,
503
521
  new Response(proc.stdout).text(),
@@ -509,6 +527,20 @@ export async function serve() {
509
527
  ) as [number, string, string];
510
528
  clearTimeout(timer);
511
529
 
530
+ const configuredHandshakeTimeout = Number(childEnv.PWMCP_TEST_CONNECTION_TIMEOUT);
531
+ const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
532
+ ? configuredHandshakeTimeout
533
+ : 30_000;
534
+ const stderr = inferSilentExtensionFailure({
535
+ status,
536
+ stdout,
537
+ stderr: rawStderr,
538
+ isOpenCommand: isOpenCmd,
539
+ hasExtensionCredentials: !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN),
540
+ elapsedMs: Date.now() - commandStartedAt,
541
+ handshakeTimeoutMs,
542
+ });
543
+
512
544
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
513
545
 
514
546
  // Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even