rechrome 1.22.0 → 1.23.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.
@@ -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,20 @@
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
+ async function sendMessageWithTimeout(message, simulateHang = false) {
5
+ let timer;
6
+ try {
7
+ return await Promise.race([
8
+ simulateHang ? new Promise(() => {
9
+ }) : chrome.runtime.sendMessage(message),
10
+ new Promise((_, reject) => {
11
+ timer = setTimeout(() => reject(new Error("Extension service worker did not respond")), BACKGROUND_RESPONSE_TIMEOUT_MS);
12
+ })
13
+ ]);
14
+ } finally {
15
+ clearTimeout(timer);
16
+ }
17
+ }
3
18
  const ConnectApp = () => {
4
19
  const [tabs, setTabs] = reactExports.useState([]);
5
20
  const [status, setStatus] = reactExports.useState(null);
@@ -13,6 +28,8 @@ const ConnectApp = () => {
13
28
  const runAsync = async () => {
14
29
  const params = new URLSearchParams(window.location.search);
15
30
  const relayUrl = params.get("mcpRelayUrl");
31
+ const recoveryAttempt = params.get("recoveryAttempt") === "1";
32
+ const hasAutomationToken = !!params.get("token");
16
33
  if (!relayUrl) {
17
34
  setError("Missing mcpRelayUrl parameter in URL.");
18
35
  return;
@@ -53,7 +70,20 @@ const ConnectApp = () => {
53
70
  });
54
71
  return;
55
72
  }
56
- const response = await chrome.runtime.sendMessage({ type: "connectionRequested", mcpRelayUrl: relayUrl, protocolVersion: requestedVersion });
73
+ let response;
74
+ try {
75
+ response = await sendMessageWithTimeout(
76
+ { type: "connectionRequested", mcpRelayUrl: relayUrl, protocolVersion: requestedVersion },
77
+ params.get("testHangOnce") === "1" && !recoveryAttempt
78
+ );
79
+ } catch (error) {
80
+ if (hasAutomationToken && !recoveryAttempt) {
81
+ setError("Extension service worker is not responding. Retrying once…");
82
+ return;
83
+ }
84
+ setError(`Extension service worker did not recover: ${error.message}`);
85
+ return;
86
+ }
57
87
  if (!response.success) {
58
88
  setError(response.error);
59
89
  return;
@@ -90,7 +120,7 @@ const ConnectApp = () => {
90
120
  const handleConnectToTab = reactExports.useCallback(async (tab, clientName = clientInfo) => {
91
121
  setShowTabList(false);
92
122
  try {
93
- const response = await chrome.runtime.sendMessage({
123
+ const response = await sendMessageWithTimeout({
94
124
  type: "connectToTab",
95
125
  tab,
96
126
  clientName
@@ -104,12 +134,18 @@ const ConnectApp = () => {
104
134
  });
105
135
  }
106
136
  } catch (e) {
137
+ const recoveryAttempt = new URLSearchParams(window.location.search).get("recoveryAttempt") === "1";
138
+ const hasAutomationToken = !!new URLSearchParams(window.location.search).get("token");
139
+ if (hasAutomationToken && !recoveryAttempt) {
140
+ setError("Extension service worker stopped during connection. Retrying once…");
141
+ return;
142
+ }
107
143
  setStatus({
108
144
  type: "error",
109
145
  message: `"${clientName}" failed to connect: ${e}`
110
146
  });
111
147
  }
112
- }, [clientInfo]);
148
+ }, [clientInfo, setError]);
113
149
  reactExports.useEffect(() => {
114
150
  const listener = (message) => {
115
151
  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.22.0",
3
+ "version": "1.23.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -6,6 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
6
6
  import { hostname, homedir } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
  import { spawn as cpSpawn } from "child_process";
9
+ import { pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
9
10
 
10
11
  export const ENV_KEY = "RECHROME_URL";
11
12
  export const DEFAULT_PORT = 13775;
@@ -350,7 +351,9 @@ const CHROME_LOCAL_STATE_PATHS = () => {
350
351
  ];
351
352
  };
352
353
 
353
- 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> {
354
357
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
355
358
  const f = file(statePath);
356
359
  if (!(await f.exists())) continue;
@@ -362,6 +365,41 @@ async function readChromeProfileCache(): Promise<Record<string, { user_name?: st
362
365
  return null;
363
366
  }
364
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
+
365
403
  async function findChromeUserDataDir(): Promise<string | null> {
366
404
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
367
405
  if (!(await file(statePath).exists())) continue;
@@ -479,15 +517,14 @@ async function listProfiles(): Promise<void> {
479
517
  }
480
518
  }
481
519
 
482
- // Header clarifies each column; email/nickname sit beside the profile dir so a bare
483
- // "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
484
521
  // from Local State — the gaia real name is deliberately never surfaced.
485
522
  const rows = [
486
- ["PROFILE", "EMAIL", "NICKNAME", ""],
523
+ ["EMAIL", "PROFILE NAME", "FOLDER", ""],
487
524
  ...Object.entries(cache).map(([dir, info]) => [
488
- dir,
489
525
  info.user_name || "",
490
526
  info.name || "",
527
+ dir,
491
528
  dir === currentDir ? "← current" : "",
492
529
  ]),
493
530
  ];
@@ -545,7 +582,16 @@ async function callServe(
545
582
  return res.json();
546
583
  }
547
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
+
548
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);
549
595
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
550
596
  const effectiveProfile = resolveEffectiveProfile(profileDirectory);
551
597
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
@@ -569,10 +615,17 @@ async function run(url: string, args: string[]) {
569
615
  if (stderr.includes('Extension connection timeout')) {
570
616
  const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
571
617
  const last = hasToken
572
- ? ` -x: extension token rejected -> extension[unknown]`
618
+ ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
573
619
  : ` -> extension[not installed] (run: rech setup)`;
574
620
  console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
575
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
+ }
576
629
  process.stderr.write(stderr);
577
630
  }
578
631
  if (stdout) process.stdout.write(stdout);
@@ -658,28 +711,80 @@ const PM_PROCESS_NAME = "rechrome";
658
711
  // Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
659
712
  // migrates an existing checkout cleanly.
660
713
  const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
661
- // oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
662
714
  const IS_WINDOWS = process.platform === "win32";
663
- const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
664
715
 
665
- // Spawn the active process manager. `env` is merged over process.env for the
666
- // child: pm2 captures the CLI's environment for the managed process (it has no
667
- // per-var flag like oxmgr's --env), so install passes daemon env this way.
668
- async function runPm(args: string[], env?: Record<string, string>): Promise<number> {
669
- const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
716
+ // Read the installed oxmgr's version (e.g. "0.4.0+winfix"), or null if oxmgr
717
+ // isn't on PATH / doesn't answer. Cached daemonManager() sits on the hot path
718
+ // of several subcommands (status, setup, install). Synchronous by design so the
719
+ // selection has no await threading through call sites.
720
+ let _oxmgrVersion: string | null | undefined;
721
+ function oxmgrVersion(bin: string): string | null {
722
+ if (_oxmgrVersion !== undefined) return _oxmgrVersion;
723
+ try {
724
+ const p = Bun.spawnSync([bin, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
725
+ const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(p.stdout?.toString() ?? "");
726
+ _oxmgrVersion = m ? m[1]! : null;
727
+ } catch {
728
+ _oxmgrVersion = null;
729
+ }
730
+ return _oxmgrVersion;
731
+ }
732
+
733
+ // Resolve (and cache) the process manager that daemonizes `rech serve`. The
734
+ // selection policy — oxmgr default, pm2 fallback, winfix-guarded on Windows,
735
+ // RECH_DAEMON_MANAGER override — lives in ./daemon-manager.js (pure + tested);
736
+ // here we just supply the runtime inputs (what's on PATH, the oxmgr version).
737
+ let _daemonMgr: DaemonManager | undefined;
738
+ function daemonManager(): DaemonManager {
739
+ if (_daemonMgr) return _daemonMgr;
740
+ const oxmgrBin = Bun.which("oxmgr");
741
+ const pm2Bin = Bun.which("pm2");
742
+ _daemonMgr = pickDaemonManager({
743
+ oxmgrBin,
744
+ pm2Bin,
745
+ oxmgrVersion: oxmgrBin ? oxmgrVersion(oxmgrBin) : null,
746
+ isWindows: IS_WINDOWS,
747
+ override: process.env.RECH_DAEMON_MANAGER,
748
+ });
749
+ return _daemonMgr;
750
+ }
751
+
752
+ // Spawn the resolved process manager by its absolute path (Bun.which). `env` is
753
+ // merged over process.env for the child: pm2 captures the CLI's environment for
754
+ // the managed process (it has no per-var flag like oxmgr's --env), so install
755
+ // passes daemon env this way.
756
+ async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
757
+ const proc = Bun.spawn([mgr.bin, ...args], {
670
758
  stdout: "inherit",
671
759
  stderr: "inherit",
672
- windowsHide: true, // no console-window flash for the bunx/pm2 child on Windows
760
+ windowsHide: true, // no console-window flash for the manager child on Windows
673
761
  ...(env ? { env: { ...process.env, ...env } } : {}),
674
762
  });
675
763
  await proc.exited;
676
764
  return proc.exitCode ?? 1;
677
765
  }
678
766
 
767
+ // oxmgr boot/login autostart. `service install` wires the platform init
768
+ // integration (Windows Task Scheduler, macOS launchd, or a systemd --user unit)
769
+ // so the daemon — and the managed serve with it — returns at login/boot, the way
770
+ // the pm2 path relied on `pm2 resurrect`. Skipped when already installed:
771
+ // re-running `service install` re-bootstraps the oxmgr daemon, which restarts
772
+ // every managed process (including the live serve). Best-effort — a failure
773
+ // leaves serve crash-managed but not login-persistent.
774
+ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
775
+ let alreadyInstalled = false;
776
+ try {
777
+ alreadyInstalled =
778
+ Bun.spawnSync([mgr.bin, "service", "status"], { stdout: "ignore", stderr: "ignore", windowsHide: true }).exitCode === 0;
779
+ } catch { /* treat a probe failure as not-installed and attempt install */ }
780
+ if (alreadyInstalled) return;
781
+ await runPm(mgr, ["service", "install"]);
782
+ }
783
+
679
784
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
680
785
  // Both render the process name verbatim, so callers can substring-match it.
681
- async function pmList(): Promise<string> {
682
- const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
786
+ async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
787
+ const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
683
788
  return await new Response(proc.stdout).text();
684
789
  }
685
790
 
@@ -741,25 +846,27 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
741
846
  if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
742
847
  if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
743
848
 
849
+ const mgr = daemonManager();
850
+
744
851
  // Drop any prior registration (current + legacy names) before re-adding.
745
- for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
852
+ for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
746
853
 
747
854
  let startCode: number;
748
- if (IS_WINDOWS) {
855
+ if (mgr.id === "pm2") {
749
856
  // pm2 captures the CLI env (passed via runPm's env) for the managed process,
750
857
  // autorestarts by default, and runs the bun binary directly with
751
858
  // `--interpreter none` (so it isn't fed to node).
752
- startCode = await runPm([
859
+ startCode = await runPm(mgr, [
753
860
  "start", bunBin,
754
861
  "--name", PM_PROCESS_NAME,
755
862
  "--interpreter", "none",
756
863
  "--cwd", home,
757
864
  "--", rechScript, "serve",
758
865
  ], daemonEnv);
759
- await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
866
+ await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
760
867
  } else {
761
868
  const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
762
- startCode = await runPm([
869
+ startCode = await runPm(mgr, [
763
870
  "start",
764
871
  "--name", PM_PROCESS_NAME,
765
872
  "--restart", "always",
@@ -767,18 +874,23 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
767
874
  ...envArgs,
768
875
  `${bunBin} ${rechScript} serve`,
769
876
  ]);
770
- await runPm(["service", "install"]);
877
+ // Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
878
+ // on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
879
+ // path's `pm2 resurrect` at login. Guarded so a re-install doesn't bounce the
880
+ // live daemon.
881
+ await oxmgrEnsureAutostart(mgr);
771
882
  }
772
883
  // Surface a failed start instead of reporting a daemon that was never registered.
773
884
  if (startCode !== 0)
774
- throw new Error(`${PM_BIN} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${PM_BIN} is installed and on PATH.`);
885
+ throw new Error(`${mgr.id} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${mgr.id} is installed and on PATH.`);
775
886
  }
776
887
 
777
888
  async function daemonUninstall(): Promise<void> {
778
- for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
779
- if (IS_WINDOWS) await runPm(["save"]);
780
- else await runPm(["service", "uninstall"]);
781
- console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
889
+ const mgr = daemonManager();
890
+ for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
891
+ if (mgr.id === "pm2") await runPm(mgr, ["save"]);
892
+ else await runPm(mgr, ["service", "uninstall"]);
893
+ console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
782
894
  }
783
895
 
784
896
  // ── Native tray (menu-bar / system-tray) icon ───────────────────────────────
@@ -1076,6 +1188,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1076
1188
  }
1077
1189
 
1078
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
+ }
1079
1200
  const { createInterface } = await import("readline");
1080
1201
  const isTTY = process.stdin.isTTY ?? false;
1081
1202
  let rl: ReturnType<typeof createInterface> | null = null;
@@ -1097,8 +1218,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1097
1218
  return new Promise<string>(r => rl!.question("", ans => r(ans || def)));
1098
1219
  };
1099
1220
 
1100
- // [1/4] Daemon
1101
- console.log("\n[1/4] Checking serve daemon...");
1221
+ // [1/5] Daemon
1222
+ console.log("\n[1/5] Checking serve daemon...");
1102
1223
 
1103
1224
  // Bind address (persists to ~/.env.local as RECH_HOST).
1104
1225
  // Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
@@ -1202,40 +1323,20 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1202
1323
  if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
1203
1324
  const userDataDir = await findChromeUserDataDir();
1204
1325
 
1205
- 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> {
1206
1327
  const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
1207
1328
  if (!available.length) return null;
1208
1329
  available.forEach(([dir, info], i) =>
1209
1330
  console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
1210
1331
  );
1211
1332
  if (opts.profile !== undefined) {
1212
- const num = parseInt(opts.profile);
1213
- if (!isNaN(num) && String(num) === opts.profile.trim()) {
1214
- // A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
1215
- // "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
1216
- // *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
1217
- // and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
1218
- // selection either way. Email is the unambiguous selector — steer toward it.
1219
- const sel = available[num - 1] ?? null;
1220
- const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
1221
- if (dirNamed && dirNamed[0] !== sel?.[0]) {
1222
- const hint = dirNamed[1].user_name || `"Profile ${num}"`;
1223
- console.error(
1224
- ` [warn] --profile ${num} = menu index ${num} → ` +
1225
- `${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
1226
- `NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
1227
- `For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
1228
- );
1229
- }
1230
- if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
1231
- 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;
1232
1339
  }
1233
- const needle = opts.profile.toLowerCase();
1234
- const match = available.find(([dir, info]) =>
1235
- dir.toLowerCase() === needle
1236
- || (info.name ?? "").toLowerCase() === needle
1237
- || (info.user_name ?? "").toLowerCase().includes(needle)
1238
- ) ?? null;
1239
1340
  if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
1240
1341
  return match;
1241
1342
  }
@@ -1243,7 +1344,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1243
1344
  console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
1244
1345
  return available[0];
1245
1346
  }
1246
- 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>");
1247
1348
  const answer = await ask("\n Profile number: ");
1248
1349
  const idx = parseInt(answer.trim()) - 1;
1249
1350
  if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
@@ -1274,7 +1375,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1274
1375
  console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
1275
1376
  console.error(` ${EXTENSION_DIST_DIR}`);
1276
1377
  console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
1277
- 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>]`);
1278
1379
  return null;
1279
1380
  }
1280
1381
  await ask("\n Press Enter after loading the extension to retry...");
@@ -1360,21 +1461,22 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1360
1461
  return null;
1361
1462
  }
1362
1463
 
1363
- // [2/4] Primary profile
1364
- console.log("\n[2/4] Select Chrome profile:");
1464
+ // [2/5] Primary profile
1465
+ console.log("\n[2/5] Select Chrome profile:");
1365
1466
  const picked = await pickProfile(new Set());
1366
1467
  if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
1367
1468
  const [profileDir, profileInfoSel] = picked;
1368
1469
  const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
1369
1470
 
1370
- // [3+4/4] Extension + token for primary profile
1371
- console.log("\n[3/4] Checking extension...");
1471
+ // [3/5] Extension + token for primary profile
1472
+ console.log("\n[3/5] Checking extension...");
1372
1473
  const profileEmail = profileInfoSel.user_name || profileDir;
1373
1474
  const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
1374
1475
  if (!primary) { rl?.close(); process.exit(1); }
1375
1476
  const { extId, token } = primary;
1376
1477
 
1377
- // 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.
1378
1480
  const rechUrl = new URL(url);
1379
1481
  if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
1380
1482
  rechUrl.searchParams.set("extension_id", extId);
@@ -1382,7 +1484,39 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1382
1484
  rechUrl.searchParams.set("profile", profileEmail);
1383
1485
  if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
1384
1486
  const newLine = `RECHROME_URL=${rechUrl.toString()}`;
1385
- 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`);
1386
1520
  if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
1387
1521
 
1388
1522
  const pwdEnvPath = join(process.cwd(), ".env.local");
@@ -1433,7 +1567,10 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1433
1567
  }
1434
1568
  rl?.close();
1435
1569
  envWatcher?.close();
1436
- 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.`);
1437
1574
  }
1438
1575
 
1439
1576
  async function status(): Promise<void> {
@@ -1460,7 +1597,7 @@ async function status(): Promise<void> {
1460
1597
  console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${PM_BIN} restart ${PM_PROCESS_NAME}\``);
1461
1598
  const pmOut = await pmList();
1462
1599
  const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
1463
- console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
1600
+ console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
1464
1601
  const registry = await readTokenRegistry();
1465
1602
  const entries = Object.entries(registry);
1466
1603
  if (entries.length) {
@@ -1482,14 +1619,13 @@ function printHelp(): void {
1482
1619
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1483
1620
 
1484
1621
  Usage:
1485
- rech setup [--profile <num|email>] [--token <tok>]
1622
+ rech setup [--profile <email|name|folder>] [--token <tok>]
1486
1623
  First-time setup: daemon + Chrome extension + config
1487
1624
  --profile selects the Chrome profile non-interactively.
1488
- A bare number is the 1-based MENU INDEX (position in the
1489
- listed order), NOT the Chrome directory "Profile N". For
1490
- scripts prefer the email (e.g. --profile you@gmail.com)
1491
- it is the only unambiguous selector; an exact directory
1492
- 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\`.
1493
1629
  --token (or RECH_TOKEN) supplies the auth token for
1494
1630
  non-TTY/agent runs, skipping the interactive paste
1495
1631
  rech provision-profile <name> --experimental [--headed]
@@ -1518,7 +1654,7 @@ Environment:
1518
1654
 
1519
1655
  Examples:
1520
1656
  rech setup
1521
- rech setup --profile 18 --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1657
+ rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1522
1658
  rech eval "() => document.title"
1523
1659
  rech open https://example.com
1524
1660
  rech screenshot`);
@@ -1564,7 +1700,7 @@ if (import.meta.main) {
1564
1700
  if (!experimental) {
1565
1701
  console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
1566
1702
  console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
1567
- 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>`);
1568
1704
  console.error(`To proceed anyway, re-run with --experimental.`);
1569
1705
  process.exit(1);
1570
1706
  }
package/rech.ts CHANGED
@@ -6,6 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
6
6
  import { hostname, homedir } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
  import { spawn as cpSpawn } from "child_process";
9
+ import { pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
9
10
 
10
11
  export const ENV_KEY = "RECHROME_URL";
11
12
  export const DEFAULT_PORT = 13775;
@@ -350,7 +351,9 @@ const CHROME_LOCAL_STATE_PATHS = () => {
350
351
  ];
351
352
  };
352
353
 
353
- 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> {
354
357
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
355
358
  const f = file(statePath);
356
359
  if (!(await f.exists())) continue;
@@ -362,6 +365,41 @@ async function readChromeProfileCache(): Promise<Record<string, { user_name?: st
362
365
  return null;
363
366
  }
364
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
+
365
403
  async function findChromeUserDataDir(): Promise<string | null> {
366
404
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
367
405
  if (!(await file(statePath).exists())) continue;
@@ -479,15 +517,14 @@ async function listProfiles(): Promise<void> {
479
517
  }
480
518
  }
481
519
 
482
- // Header clarifies each column; email/nickname sit beside the profile dir so a bare
483
- // "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
484
521
  // from Local State — the gaia real name is deliberately never surfaced.
485
522
  const rows = [
486
- ["PROFILE", "EMAIL", "NICKNAME", ""],
523
+ ["EMAIL", "PROFILE NAME", "FOLDER", ""],
487
524
  ...Object.entries(cache).map(([dir, info]) => [
488
- dir,
489
525
  info.user_name || "",
490
526
  info.name || "",
527
+ dir,
491
528
  dir === currentDir ? "← current" : "",
492
529
  ]),
493
530
  ];
@@ -545,7 +582,16 @@ async function callServe(
545
582
  return res.json();
546
583
  }
547
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
+
548
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);
549
595
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
550
596
  const effectiveProfile = resolveEffectiveProfile(profileDirectory);
551
597
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
@@ -569,10 +615,17 @@ async function run(url: string, args: string[]) {
569
615
  if (stderr.includes('Extension connection timeout')) {
570
616
  const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
571
617
  const last = hasToken
572
- ? ` -x: extension token rejected -> extension[unknown]`
618
+ ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
573
619
  : ` -> extension[not installed] (run: rech setup)`;
574
620
  console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
575
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
+ }
576
629
  process.stderr.write(stderr);
577
630
  }
578
631
  if (stdout) process.stdout.write(stdout);
@@ -658,28 +711,80 @@ const PM_PROCESS_NAME = "rechrome";
658
711
  // Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
659
712
  // migrates an existing checkout cleanly.
660
713
  const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
661
- // oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
662
714
  const IS_WINDOWS = process.platform === "win32";
663
- const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
664
715
 
665
- // Spawn the active process manager. `env` is merged over process.env for the
666
- // child: pm2 captures the CLI's environment for the managed process (it has no
667
- // per-var flag like oxmgr's --env), so install passes daemon env this way.
668
- async function runPm(args: string[], env?: Record<string, string>): Promise<number> {
669
- const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
716
+ // Read the installed oxmgr's version (e.g. "0.4.0+winfix"), or null if oxmgr
717
+ // isn't on PATH / doesn't answer. Cached daemonManager() sits on the hot path
718
+ // of several subcommands (status, setup, install). Synchronous by design so the
719
+ // selection has no await threading through call sites.
720
+ let _oxmgrVersion: string | null | undefined;
721
+ function oxmgrVersion(bin: string): string | null {
722
+ if (_oxmgrVersion !== undefined) return _oxmgrVersion;
723
+ try {
724
+ const p = Bun.spawnSync([bin, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
725
+ const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(p.stdout?.toString() ?? "");
726
+ _oxmgrVersion = m ? m[1]! : null;
727
+ } catch {
728
+ _oxmgrVersion = null;
729
+ }
730
+ return _oxmgrVersion;
731
+ }
732
+
733
+ // Resolve (and cache) the process manager that daemonizes `rech serve`. The
734
+ // selection policy — oxmgr default, pm2 fallback, winfix-guarded on Windows,
735
+ // RECH_DAEMON_MANAGER override — lives in ./daemon-manager.ts (pure + tested);
736
+ // here we just supply the runtime inputs (what's on PATH, the oxmgr version).
737
+ let _daemonMgr: DaemonManager | undefined;
738
+ function daemonManager(): DaemonManager {
739
+ if (_daemonMgr) return _daemonMgr;
740
+ const oxmgrBin = Bun.which("oxmgr");
741
+ const pm2Bin = Bun.which("pm2");
742
+ _daemonMgr = pickDaemonManager({
743
+ oxmgrBin,
744
+ pm2Bin,
745
+ oxmgrVersion: oxmgrBin ? oxmgrVersion(oxmgrBin) : null,
746
+ isWindows: IS_WINDOWS,
747
+ override: process.env.RECH_DAEMON_MANAGER,
748
+ });
749
+ return _daemonMgr;
750
+ }
751
+
752
+ // Spawn the resolved process manager by its absolute path (Bun.which). `env` is
753
+ // merged over process.env for the child: pm2 captures the CLI's environment for
754
+ // the managed process (it has no per-var flag like oxmgr's --env), so install
755
+ // passes daemon env this way.
756
+ async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
757
+ const proc = Bun.spawn([mgr.bin, ...args], {
670
758
  stdout: "inherit",
671
759
  stderr: "inherit",
672
- windowsHide: true, // no console-window flash for the bunx/pm2 child on Windows
760
+ windowsHide: true, // no console-window flash for the manager child on Windows
673
761
  ...(env ? { env: { ...process.env, ...env } } : {}),
674
762
  });
675
763
  await proc.exited;
676
764
  return proc.exitCode ?? 1;
677
765
  }
678
766
 
767
+ // oxmgr boot/login autostart. `service install` wires the platform init
768
+ // integration (Windows Task Scheduler, macOS launchd, or a systemd --user unit)
769
+ // so the daemon — and the managed serve with it — returns at login/boot, the way
770
+ // the pm2 path relied on `pm2 resurrect`. Skipped when already installed:
771
+ // re-running `service install` re-bootstraps the oxmgr daemon, which restarts
772
+ // every managed process (including the live serve). Best-effort — a failure
773
+ // leaves serve crash-managed but not login-persistent.
774
+ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
775
+ let alreadyInstalled = false;
776
+ try {
777
+ alreadyInstalled =
778
+ Bun.spawnSync([mgr.bin, "service", "status"], { stdout: "ignore", stderr: "ignore", windowsHide: true }).exitCode === 0;
779
+ } catch { /* treat a probe failure as not-installed and attempt install */ }
780
+ if (alreadyInstalled) return;
781
+ await runPm(mgr, ["service", "install"]);
782
+ }
783
+
679
784
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
680
785
  // Both render the process name verbatim, so callers can substring-match it.
681
- async function pmList(): Promise<string> {
682
- const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
786
+ async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
787
+ const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
683
788
  return await new Response(proc.stdout).text();
684
789
  }
685
790
 
@@ -741,25 +846,27 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
741
846
  if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
742
847
  if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
743
848
 
849
+ const mgr = daemonManager();
850
+
744
851
  // Drop any prior registration (current + legacy names) before re-adding.
745
- for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
852
+ for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
746
853
 
747
854
  let startCode: number;
748
- if (IS_WINDOWS) {
855
+ if (mgr.id === "pm2") {
749
856
  // pm2 captures the CLI env (passed via runPm's env) for the managed process,
750
857
  // autorestarts by default, and runs the bun binary directly with
751
858
  // `--interpreter none` (so it isn't fed to node).
752
- startCode = await runPm([
859
+ startCode = await runPm(mgr, [
753
860
  "start", bunBin,
754
861
  "--name", PM_PROCESS_NAME,
755
862
  "--interpreter", "none",
756
863
  "--cwd", home,
757
864
  "--", rechScript, "serve",
758
865
  ], daemonEnv);
759
- await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
866
+ await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
760
867
  } else {
761
868
  const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
762
- startCode = await runPm([
869
+ startCode = await runPm(mgr, [
763
870
  "start",
764
871
  "--name", PM_PROCESS_NAME,
765
872
  "--restart", "always",
@@ -767,18 +874,23 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
767
874
  ...envArgs,
768
875
  `${bunBin} ${rechScript} serve`,
769
876
  ]);
770
- await runPm(["service", "install"]);
877
+ // Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
878
+ // on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
879
+ // path's `pm2 resurrect` at login. Guarded so a re-install doesn't bounce the
880
+ // live daemon.
881
+ await oxmgrEnsureAutostart(mgr);
771
882
  }
772
883
  // Surface a failed start instead of reporting a daemon that was never registered.
773
884
  if (startCode !== 0)
774
- throw new Error(`${PM_BIN} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${PM_BIN} is installed and on PATH.`);
885
+ throw new Error(`${mgr.id} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${mgr.id} is installed and on PATH.`);
775
886
  }
776
887
 
777
888
  async function daemonUninstall(): Promise<void> {
778
- for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
779
- if (IS_WINDOWS) await runPm(["save"]);
780
- else await runPm(["service", "uninstall"]);
781
- console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
889
+ const mgr = daemonManager();
890
+ for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
891
+ if (mgr.id === "pm2") await runPm(mgr, ["save"]);
892
+ else await runPm(mgr, ["service", "uninstall"]);
893
+ console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
782
894
  }
783
895
 
784
896
  // ── Native tray (menu-bar / system-tray) icon ───────────────────────────────
@@ -1076,6 +1188,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1076
1188
  }
1077
1189
 
1078
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
+ }
1079
1200
  const { createInterface } = await import("readline");
1080
1201
  const isTTY = process.stdin.isTTY ?? false;
1081
1202
  let rl: ReturnType<typeof createInterface> | null = null;
@@ -1097,8 +1218,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1097
1218
  return new Promise<string>(r => rl!.question("", ans => r(ans || def)));
1098
1219
  };
1099
1220
 
1100
- // [1/4] Daemon
1101
- console.log("\n[1/4] Checking serve daemon...");
1221
+ // [1/5] Daemon
1222
+ console.log("\n[1/5] Checking serve daemon...");
1102
1223
 
1103
1224
  // Bind address (persists to ~/.env.local as RECH_HOST).
1104
1225
  // Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
@@ -1202,40 +1323,20 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1202
1323
  if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
1203
1324
  const userDataDir = await findChromeUserDataDir();
1204
1325
 
1205
- 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> {
1206
1327
  const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
1207
1328
  if (!available.length) return null;
1208
1329
  available.forEach(([dir, info], i) =>
1209
1330
  console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
1210
1331
  );
1211
1332
  if (opts.profile !== undefined) {
1212
- const num = parseInt(opts.profile);
1213
- if (!isNaN(num) && String(num) === opts.profile.trim()) {
1214
- // A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
1215
- // "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
1216
- // *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
1217
- // and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
1218
- // selection either way. Email is the unambiguous selector — steer toward it.
1219
- const sel = available[num - 1] ?? null;
1220
- const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
1221
- if (dirNamed && dirNamed[0] !== sel?.[0]) {
1222
- const hint = dirNamed[1].user_name || `"Profile ${num}"`;
1223
- console.error(
1224
- ` [warn] --profile ${num} = menu index ${num} → ` +
1225
- `${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
1226
- `NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
1227
- `For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
1228
- );
1229
- }
1230
- if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
1231
- 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;
1232
1339
  }
1233
- const needle = opts.profile.toLowerCase();
1234
- const match = available.find(([dir, info]) =>
1235
- dir.toLowerCase() === needle
1236
- || (info.name ?? "").toLowerCase() === needle
1237
- || (info.user_name ?? "").toLowerCase().includes(needle)
1238
- ) ?? null;
1239
1340
  if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
1240
1341
  return match;
1241
1342
  }
@@ -1243,7 +1344,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1243
1344
  console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
1244
1345
  return available[0];
1245
1346
  }
1246
- 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>");
1247
1348
  const answer = await ask("\n Profile number: ");
1248
1349
  const idx = parseInt(answer.trim()) - 1;
1249
1350
  if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
@@ -1274,7 +1375,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1274
1375
  console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
1275
1376
  console.error(` ${EXTENSION_DIST_DIR}`);
1276
1377
  console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
1277
- 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>]`);
1278
1379
  return null;
1279
1380
  }
1280
1381
  await ask("\n Press Enter after loading the extension to retry...");
@@ -1360,21 +1461,22 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1360
1461
  return null;
1361
1462
  }
1362
1463
 
1363
- // [2/4] Primary profile
1364
- console.log("\n[2/4] Select Chrome profile:");
1464
+ // [2/5] Primary profile
1465
+ console.log("\n[2/5] Select Chrome profile:");
1365
1466
  const picked = await pickProfile(new Set());
1366
1467
  if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
1367
1468
  const [profileDir, profileInfoSel] = picked;
1368
1469
  const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
1369
1470
 
1370
- // [3+4/4] Extension + token for primary profile
1371
- console.log("\n[3/4] Checking extension...");
1471
+ // [3/5] Extension + token for primary profile
1472
+ console.log("\n[3/5] Checking extension...");
1372
1473
  const profileEmail = profileInfoSel.user_name || profileDir;
1373
1474
  const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
1374
1475
  if (!primary) { rl?.close(); process.exit(1); }
1375
1476
  const { extId, token } = primary;
1376
1477
 
1377
- // 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.
1378
1480
  const rechUrl = new URL(url);
1379
1481
  if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
1380
1482
  rechUrl.searchParams.set("extension_id", extId);
@@ -1382,7 +1484,39 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1382
1484
  rechUrl.searchParams.set("profile", profileEmail);
1383
1485
  if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
1384
1486
  const newLine = `RECHROME_URL=${rechUrl.toString()}`;
1385
- 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`);
1386
1520
  if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
1387
1521
 
1388
1522
  const pwdEnvPath = join(process.cwd(), ".env.local");
@@ -1433,7 +1567,10 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1433
1567
  }
1434
1568
  rl?.close();
1435
1569
  envWatcher?.close();
1436
- 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.`);
1437
1574
  }
1438
1575
 
1439
1576
  async function status(): Promise<void> {
@@ -1460,7 +1597,7 @@ async function status(): Promise<void> {
1460
1597
  console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${PM_BIN} restart ${PM_PROCESS_NAME}\``);
1461
1598
  const pmOut = await pmList();
1462
1599
  const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
1463
- console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
1600
+ console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
1464
1601
  const registry = await readTokenRegistry();
1465
1602
  const entries = Object.entries(registry);
1466
1603
  if (entries.length) {
@@ -1482,14 +1619,13 @@ function printHelp(): void {
1482
1619
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1483
1620
 
1484
1621
  Usage:
1485
- rech setup [--profile <num|email>] [--token <tok>]
1622
+ rech setup [--profile <email|name|folder>] [--token <tok>]
1486
1623
  First-time setup: daemon + Chrome extension + config
1487
1624
  --profile selects the Chrome profile non-interactively.
1488
- A bare number is the 1-based MENU INDEX (position in the
1489
- listed order), NOT the Chrome directory "Profile N". For
1490
- scripts prefer the email (e.g. --profile you@gmail.com)
1491
- it is the only unambiguous selector; an exact directory
1492
- 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\`.
1493
1629
  --token (or RECH_TOKEN) supplies the auth token for
1494
1630
  non-TTY/agent runs, skipping the interactive paste
1495
1631
  rech provision-profile <name> --experimental [--headed]
@@ -1518,7 +1654,7 @@ Environment:
1518
1654
 
1519
1655
  Examples:
1520
1656
  rech setup
1521
- rech setup --profile 18 --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1657
+ rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1522
1658
  rech eval "() => document.title"
1523
1659
  rech open https://example.com
1524
1660
  rech screenshot`);
@@ -1564,7 +1700,7 @@ if (import.meta.main) {
1564
1700
  if (!experimental) {
1565
1701
  console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
1566
1702
  console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
1567
- 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>`);
1568
1704
  console.error(`To proceed anyway, re-run with --experimental.`);
1569
1705
  process.exit(1);
1570
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