rechrome 1.12.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,50 +1,48 @@
1
- import { c as clientExports, j as jsxRuntimeExports, r as reactExports, B as Button, A as AuthTokenSection, T as TabItem, g as getOrCreateAuthToken } from "./authToken.js";
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
3
  const ConnectApp = () => {
4
4
  const [tabs, setTabs] = reactExports.useState([]);
5
5
  const [status, setStatus] = reactExports.useState(null);
6
- const [showButtons, setShowButtons] = reactExports.useState(true);
7
6
  const [showTabList, setShowTabList] = reactExports.useState(true);
8
7
  const [clientInfo, setClientInfo] = reactExports.useState("unknown");
9
- const [mcpRelayUrl, setMcpRelayUrl] = reactExports.useState("");
10
- const [newTab, setNewTab] = reactExports.useState(false);
8
+ const setError = reactExports.useCallback((message) => {
9
+ setShowTabList(false);
10
+ setStatus({ type: "error", message });
11
+ }, []);
11
12
  reactExports.useEffect(() => {
12
13
  const runAsync = async () => {
13
14
  const params = new URLSearchParams(window.location.search);
14
15
  const relayUrl = params.get("mcpRelayUrl");
15
16
  if (!relayUrl) {
16
- handleReject("Missing mcpRelayUrl parameter in URL.");
17
+ setError("Missing mcpRelayUrl parameter in URL.");
17
18
  return;
18
19
  }
19
20
  try {
20
21
  const host = new URL(relayUrl).hostname;
21
22
  if (host !== "127.0.0.1" && host !== "[::1]") {
22
- handleReject(`MCP extension only allows loopback connections (127.0.0.1 or [::1]). Received host: ${host}`);
23
+ setError(`Playwright extension only allows loopback connections (127.0.0.1 or [::1]). Received host: ${host}`);
23
24
  return;
24
25
  }
25
26
  } catch (e) {
26
- handleReject(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`);
27
+ setError(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`);
27
28
  return;
28
29
  }
29
- setMcpRelayUrl(relayUrl);
30
- let info = "unknown";
31
30
  try {
32
31
  const client = JSON.parse(params.get("client") || "{}");
33
- info = `${client.name}/${client.version}`;
32
+ const info = `${client.name || "unknown"}`;
34
33
  setClientInfo(info);
35
34
  setStatus({
36
35
  type: "connecting",
37
- message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
36
+ message: `"${info}" is trying to connect to the Playwright Extension.`
38
37
  });
39
38
  } catch (e) {
40
39
  setStatus({ type: "error", message: "Failed to parse client version." });
41
40
  return;
42
41
  }
43
42
  const parsedVersion = parseInt(params.get("protocolVersion") ?? "", 10);
44
- const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
45
- if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
43
+ const requestedVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
44
+ if (requestedVersion > SUPPORTED_PROTOCOL_VERSION) {
46
45
  const extensionVersion = chrome.runtime.getManifest().version;
47
- setShowButtons(false);
48
46
  setShowTabList(false);
49
47
  setStatus({
50
48
  type: "error",
@@ -54,37 +52,33 @@ const ConnectApp = () => {
54
52
  });
55
53
  return;
56
54
  }
55
+ const response = await chrome.runtime.sendMessage({ type: "connectionRequested", mcpRelayUrl: relayUrl, protocolVersion: requestedVersion });
56
+ if (!response.success) {
57
+ setError(response.error);
58
+ return;
59
+ }
57
60
  const expectedToken = getOrCreateAuthToken();
58
61
  const token = params.get("token");
59
62
  if (token === expectedToken) {
60
- await connectToMCPRelay(relayUrl);
61
- await handleConnectToTab(void 0, info);
63
+ await handleConnectToTab();
62
64
  return;
63
65
  }
64
66
  if (token) {
65
- handleReject("Invalid token provided.");
67
+ setError("Invalid token provided.");
66
68
  return;
67
69
  }
68
- await connectToMCPRelay(relayUrl);
69
- if (params.get("newTab") === "true") {
70
- setNewTab(true);
70
+ if (params.get("newTab") === "true")
71
71
  setShowTabList(false);
72
- } else {
72
+ else
73
73
  await loadTabs();
74
- }
75
74
  };
76
75
  void runAsync();
76
+ const keepalive = setInterval(() => {
77
+ chrome.runtime.sendMessage({ type: "keepalive" }).catch(() => {
78
+ });
79
+ }, 2e4);
80
+ return () => clearInterval(keepalive);
77
81
  }, []);
78
- const handleReject = reactExports.useCallback((message) => {
79
- setShowButtons(false);
80
- setShowTabList(false);
81
- setStatus({ type: "error", message });
82
- }, []);
83
- const connectToMCPRelay = reactExports.useCallback(async (mcpRelayUrl2) => {
84
- const response = await chrome.runtime.sendMessage({ type: "connectToMCPRelay", mcpRelayUrl: mcpRelayUrl2 });
85
- if (!response.success)
86
- handleReject(response.error);
87
- }, [handleReject]);
88
82
  const loadTabs = reactExports.useCallback(async () => {
89
83
  const response = await chrome.runtime.sendMessage({ type: "getTabs" });
90
84
  if (response.success)
@@ -92,58 +86,55 @@ const ConnectApp = () => {
92
86
  else
93
87
  setStatus({ type: "error", message: "Failed to load tabs: " + response.error });
94
88
  }, []);
95
- const handleConnectToTab = reactExports.useCallback(async (tab, clientInfoOverride) => {
96
- const displayName = clientInfoOverride || clientInfo;
97
- setShowButtons(false);
89
+ const handleConnectToTab = reactExports.useCallback(async (tab) => {
98
90
  setShowTabList(false);
99
91
  try {
100
92
  const response = await chrome.runtime.sendMessage({
101
93
  type: "connectToTab",
102
- mcpRelayUrl,
103
- tabId: tab?.id,
104
- windowId: tab?.windowId
94
+ tab,
95
+ clientName: clientInfo
105
96
  });
106
- if (response?.success) {
107
- setStatus({ type: "connected", message: `MCP client "${displayName}" connected. Do not close this tab — it maintains the connection to the browser. Closing it will disconnect the session.` });
97
+ if (response == null ? void 0 : response.success) {
98
+ setStatus({ type: "connected", message: `"${clientInfo}" connected.` });
108
99
  } else {
109
100
  setStatus({
110
101
  type: "error",
111
- message: response?.error || `MCP client "${displayName}" failed to connect.`
102
+ message: (response == null ? void 0 : response.error) || `"${clientInfo}" failed to connect.`
112
103
  });
113
104
  }
114
105
  } catch (e) {
115
106
  setStatus({
116
107
  type: "error",
117
- message: `MCP client "${displayName}" failed to connect: ${e}`
108
+ message: `"${clientInfo}" failed to connect: ${e}`
118
109
  });
119
110
  }
120
- }, [clientInfo, mcpRelayUrl]);
111
+ }, [clientInfo]);
121
112
  reactExports.useEffect(() => {
122
113
  const listener = (message) => {
123
- if (message.type === "connectionTimeout")
124
- handleReject("Connection timed out.");
114
+ if (message.type === "pendingConnectionClosed") {
115
+ setError("Pending client connection closed.");
116
+ document.title = "Playwright Extension";
117
+ }
125
118
  };
126
119
  chrome.runtime.onMessage.addListener(listener);
127
120
  return () => {
128
121
  chrome.runtime.onMessage.removeListener(listener);
129
122
  };
130
- }, [handleReject]);
123
+ }, [setError]);
131
124
  return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "app-container", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "content-wrapper", children: [
132
- status && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "status-container", children: [
133
- /* @__PURE__ */ jsxRuntimeExports.jsx(StatusBanner, { status }),
134
- showButtons && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "button-container", children: newTab ? /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
135
- /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => handleConnectToTab(), children: "Allow" }),
136
- /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "reject", onClick: () => handleReject("Connection rejected. This tab can be closed."), children: "Reject" })
137
- ] }) : /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "reject", onClick: () => handleReject("Connection rejected. This tab can be closed."), children: "Reject" }) })
125
+ status && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "status-container", children: /* @__PURE__ */ jsxRuntimeExports.jsx(StatusBanner, { status }) }),
126
+ (status == null ? void 0 : status.type) === "connecting" && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "warning-banner", children: [
127
+ /* @__PURE__ */ jsxRuntimeExports.jsx("strong", { children: "⚠️ Warning:" }),
128
+ " Allowing this connection exposes the entire browser to the client, including any signed-in sessions, cookies, and content in other tabs and windows. Once approved, the client may also be able to reconnect later without showing this dialog again, unless you regenerate the token below and then restart the browser."
138
129
  ] }),
139
- status?.type === "connecting" && /* @__PURE__ */ jsxRuntimeExports.jsx(AuthTokenSection, {}),
130
+ (status == null ? void 0 : status.type) === "connecting" && /* @__PURE__ */ jsxRuntimeExports.jsx(AuthTokenSection, {}),
140
131
  showTabList && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
141
- /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "Select page to expose to MCP server:" }),
132
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "You can drag tabs into the Playwright group later to make them accessible to the client. Optionally, select a tab to allow and immediately switch to it:" }),
142
133
  /* @__PURE__ */ jsxRuntimeExports.jsx("div", { children: tabs.map((tab) => /* @__PURE__ */ jsxRuntimeExports.jsx(
143
134
  TabItem,
144
135
  {
145
136
  tab,
146
- button: /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => handleConnectToTab(tab), children: "Connect" })
137
+ button: /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => handleConnectToTab(tab), children: "Allow & select" })
147
138
  },
148
139
  tab.id
149
140
  )) })
@@ -151,15 +142,16 @@ const ConnectApp = () => {
151
142
  ] }) });
152
143
  };
153
144
  const VersionMismatchError = ({ extensionVersion }) => {
154
- const readmeUrl = "https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md";
155
- const latestReleaseUrl = "https://github.com/microsoft/playwright-mcp/releases/latest";
145
+ const readmeUrl = "https://github.com/microsoft/playwright/blob/main/packages/extension/README.md";
146
+ const chromeWebStoreUrl = "https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm";
156
147
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
157
- "Playwright MCP version trying to connect requires newer extension version (current version: ",
148
+ "Playwright client trying to connect requires newer extension version (current version: ",
158
149
  extensionVersion,
159
150
  ").",
160
151
  " ",
161
- /* @__PURE__ */ jsxRuntimeExports.jsx("a", { href: latestReleaseUrl, children: "Click here" }),
162
- " to download latest version of the extension, then drag and drop it into the Chrome Extensions page.",
152
+ "Update ",
153
+ /* @__PURE__ */ jsxRuntimeExports.jsx("a", { href: chromeWebStoreUrl, target: "_blank", rel: "noopener noreferrer", children: "Playwright Extension" }),
154
+ " from the Chrome Web Store to the latest version.",
163
155
  " ",
164
156
  "See ",
165
157
  /* @__PURE__ */ jsxRuntimeExports.jsx("a", { href: readmeUrl, target: "_blank", rel: "noopener noreferrer", children: "installation instructions" }),
@@ -1,66 +1,47 @@
1
- import { c as clientExports, j as jsxRuntimeExports, r as reactExports, T as TabItem, B as Button, A as AuthTokenSection } from "./authToken.js";
1
+ import { c as clientExports, j as jsxRuntimeExports, r as reactExports, B as Button, T as TabItem, A as AuthTokenSection } from "./authToken.js";
2
2
  const StatusApp = () => {
3
- const [connections, setConnections] = reactExports.useState([]);
3
+ const [connectedTabs, setConnectedTabs] = reactExports.useState([]);
4
+ const [clientName, setClientName] = reactExports.useState(void 0);
4
5
  reactExports.useEffect(() => {
5
6
  void loadStatus();
6
7
  }, []);
7
8
  const loadStatus = async () => {
8
- const { connections: rawConnections = [] } = await chrome.runtime.sendMessage({ type: "getConnectionStatus" });
9
- const fetchTab = async (id) => {
10
- try {
11
- const tab = await chrome.tabs.get(id);
12
- return { id: tab.id, windowId: tab.windowId, title: tab.title, url: tab.url, favIconUrl: tab.favIconUrl };
13
- } catch {
14
- return null;
15
- }
16
- };
17
- const resolved = await Promise.all(
18
- rawConnections.map(async (c) => {
19
- const connectedTab = await fetchTab(c.connectedTabId) ?? void 0;
20
- const playwrightTabs = (await Promise.all(c.playwrightTabIds.map(fetchTab))).filter((t) => t !== null);
21
- return { ...c, connectedTab, playwrightTabs };
22
- })
23
- );
24
- setConnections(resolved);
9
+ const { connectedTabIds, clientName: clientName2 } = await chrome.runtime.sendMessage({ type: "getConnectionStatus" });
10
+ const tabs = await Promise.all((connectedTabIds ?? []).map((tabId) => chrome.tabs.get(tabId)));
11
+ setConnectedTabs(tabs);
12
+ setClientName(clientName2);
25
13
  };
26
14
  const openTab = async (tabId) => {
27
15
  await chrome.tabs.update(tabId, { active: true });
28
16
  window.close();
29
17
  };
30
- const disconnect = async (mcpRelayUrl) => {
31
- await chrome.runtime.sendMessage({ type: "disconnect", mcpRelayUrl });
32
- void loadStatus();
18
+ const disconnect = async () => {
19
+ await chrome.runtime.sendMessage({ type: "disconnect" });
20
+ window.close();
33
21
  };
34
22
  return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "app-container", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "content-wrapper", children: [
35
- connections.length === 0 ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "status-banner", children: "No MCP clients are currently connected." }) : connections.map((c, i) => /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
36
- connections.length > 1 && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "tab-section-title", children: [
37
- "Instance ",
38
- i + 1,
39
- ":"
40
- ] }),
41
- c.connectedTab && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
42
- /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "Page with connected MCP client:" }),
43
- /* @__PURE__ */ jsxRuntimeExports.jsx(
44
- TabItem,
45
- {
46
- tab: c.connectedTab,
47
- button: /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => disconnect(c.mcpRelayUrl), children: "Disconnect" }),
48
- onClick: () => openTab(c.connectedTabId)
49
- }
50
- )
23
+ connectedTabs.length > 0 ? /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
24
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "connection-header", children: [
25
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "client-info", children: [
26
+ "Connected to ",
27
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("strong", { children: [
28
+ '"',
29
+ clientName || "unknown",
30
+ '"'
31
+ ] })
32
+ ] }),
33
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: disconnect, children: "Disconnect" })
51
34
  ] }),
52
- c.playwrightTabs.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
53
- /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "Playwright managed tabs:" }),
54
- c.playwrightTabs.map((tab) => /* @__PURE__ */ jsxRuntimeExports.jsx(
55
- TabItem,
56
- {
57
- tab,
58
- onClick: () => openTab(tab.id)
59
- },
60
- tab.id
61
- ))
62
- ] })
63
- ] }, c.mcpRelayUrl)),
35
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: connectedTabs.length === 1 ? "Accessible page:" : "Accessible pages:" }),
36
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { children: connectedTabs.map((tab) => /* @__PURE__ */ jsxRuntimeExports.jsx(
37
+ TabItem,
38
+ {
39
+ tab,
40
+ onClick: () => openTab(tab.id)
41
+ },
42
+ tab.id
43
+ )) })
44
+ ] }) : /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "status-banner", children: "No clients are currently connected. You can connect from the Playwright CLI or MCP server by passing the --extension flag." }),
64
45
  /* @__PURE__ */ jsxRuntimeExports.jsx(AuthTokenSection, {})
65
46
  ] }) });
66
47
  };
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "manifest_version": 3,
3
- "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyr3kV2bBGDawVu1+vxLx2epYDN18tw/MSHbBunj5XLXho2gYcU2IYjBFZBt65A48IDJJ94D7LEnoFuaJScjEbxmPaPqvE1UrvplkwWiUPoN/3d4bshwx+wybln1oM4jnO/qGcOkyWZ28RrEhewgRznPYisJhVkBZWi+YoSYaQTkURLxi3CZ68xmWVQLyec3v3sxnOYu+ibOBtBgOFI+WYEBQAvI38y4jUdOTyoHyWw32Plr+y/4fnOzxEXVs7emCdQpVeZuNbru4x1UltrhwtXgl37mIGoOQmf6PzafCO9MaQ+mmRSY5e8Ht/Z5/eWY30mwjbc7H3HAtYOUaPZbp8wIDAQAB",
4
- "name": "Playwright MCP Bridge (multi-tab)",
5
- "version": "0.0.68.3",
6
- "description": "Share browser tabs with Playwright MCP server (multi-tab fork: Playwright can open and control multiple tabs)",
3
+ "name": "Playwright Extension",
4
+ "version": "0.2.1",
5
+ "description": "Connect your browser to AI agents through Playwright MCP server and CLI. Enables AI-driven web testing, debugging, and automation.",
7
6
  "permissions": [
8
7
  "debugger",
9
8
  "activeTab",
10
- "tabs"
9
+ "tabs",
10
+ "tabGroups"
11
11
  ],
12
12
  "host_permissions": [
13
13
  "<all_urls>"
@@ -17,7 +17,7 @@
17
17
  "type": "module"
18
18
  },
19
19
  "action": {
20
- "default_title": "Playwright MCP Bridge",
20
+ "default_title": "Playwright Extension",
21
21
  "default_icon": {
22
22
  "16": "icons/icon-16.png",
23
23
  "32": "icons/icon-32.png",
@@ -30,5 +30,6 @@
30
30
  "32": "icons/icon-32.png",
31
31
  "48": "icons/icon-48.png",
32
32
  "128": "icons/icon-128.png"
33
- }
33
+ },
34
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwRsUUO4mmbCi4JpmrIoIw31iVW9+xUJRZ6nSzya17PQkaUPDxe1IpgM+vpd/xB6mJWlJSyE1Lj95c0sbomGfVY1M0zUeKbaRVcAb+/a6m59gNR+ubFlmTX0nK9/8fE2FpRB9D+4N5jyeIPQuASW/0oswI2/ijK7hH5NTRX8gWc/ROMSgUj7rKhTAgBrICt/NsStgDPsxRTPPJnhJ/ViJtM1P5KsSYswE987DPoFnpmkFpq8g1ae0eYbQfXy55ieaacC4QWyJPj3daU2kMfBQw7MXnnk0H/WDxouMOIHnd8MlQxpEMqAihj7KpuONH+MUhuj9HEQo4df6bSaIuQ0b4QIDAQAB"
34
35
  }
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Playwright MCP Bridge Status</title>
6
+ <title>Playwright Extension Status</title>
7
7
  <script type="module" crossorigin src="/lib/ui/status.js"></script>
8
8
  <link rel="modulepreload" crossorigin href="/lib/ui/authToken.js">
9
9
  <link rel="stylesheet" crossorigin href="/lib/ui/authToken.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.12.2",
3
+ "version": "1.13.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -254,7 +254,7 @@ export const EXTENSION_DIST_DIR = join(process.env.HOME!, ".rechrome", "extensio
254
254
 
255
255
  // With the manifest `key` field set, Chrome derives this ID deterministically from the key (not the path),
256
256
  // so we can locate the extension by ID even when the on-disk path differs from what Chrome stored.
257
- export const EXTENSION_ID = "fokngfbogklgiffokdnekajodmhgfnhk";
257
+ export const EXTENSION_ID = "mmlmfjhmonkocbjadbfplnigmagldckm";
258
258
 
259
259
  async function ensureExtensionDistInstalled(): Promise<string> {
260
260
  const source = existsSync(BUNDLED_EXTENSION_DIST_DIR)
@@ -279,28 +279,41 @@ async function findInstalledExtension(
279
279
  const cache = await readChromeProfileCache();
280
280
  const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
281
281
  // Resolve our known-good install paths up front for path-based fallback matching.
282
+ // LEGACY_EXTENSION_DIST_DIR is intentionally excluded: it points at the pre-V2 multi-tab
283
+ // bridge, which is incompatible with the current cdpRelayV2 relay — matching it would hand
284
+ // setup a stale, broken extension.
282
285
  const knownPaths = new Set<string>();
283
- for (const p of [EXTENSION_DIST_DIR, BUNDLED_EXTENSION_DIST_DIR, LEGACY_EXTENSION_DIST_DIR]) {
286
+ for (const p of [EXTENSION_DIST_DIR, BUNDLED_EXTENSION_DIST_DIR]) {
284
287
  try { knownPaths.add(realpathSync(p)); } catch {}
285
288
  }
289
+ // Read each profile's settings once so we can prioritize stable-ID matches over path fallbacks.
290
+ const perProfile: Array<{ prof: string; settings: Record<string, any> }> = [];
286
291
  for (const prof of profiles) {
287
292
  const prefsPath = join(userDataDir, prof, "Secure Preferences");
288
293
  const f = file(prefsPath);
289
294
  if (!(await f.exists())) continue;
290
295
  try {
291
296
  const data = JSON.parse(await f.text());
292
- const settings = data?.extensions?.settings ?? {};
293
- for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
294
- if (!info?.path || info.state === 0) continue; // state 0 = explicitly disabled
295
- // Primary: stable ID match (works when manifest `key` is set, regardless of path).
296
- if (extId === EXTENSION_ID) return { id: extId, profile: prof };
297
- // Fallback: path equality for legacy installs without a stable key.
298
- let storedPath = info.path as string;
299
- try { storedPath = realpathSync(storedPath); } catch {}
300
- if (knownPaths.has(storedPath)) return { id: extId, profile: prof };
301
- }
297
+ perProfile.push({ prof, settings: (data?.extensions?.settings ?? {}) as Record<string, any> });
302
298
  } catch {}
303
299
  }
300
+ // Pass 1: stable ID match (manifest `key` set, path-independent). This must win over any path
301
+ // fallback so a stale legacy install sitting on a known path can't shadow the current extension.
302
+ for (const { prof, settings } of perProfile) {
303
+ for (const [extId, info] of Object.entries(settings)) {
304
+ if (!info?.path || info.state === 0) continue; // state 0 = explicitly disabled
305
+ if (extId === EXTENSION_ID) return { id: extId, profile: prof };
306
+ }
307
+ }
308
+ // Pass 2: path equality fallback for legacy keyless installs without a stable ID.
309
+ for (const { prof, settings } of perProfile) {
310
+ for (const [extId, info] of Object.entries(settings)) {
311
+ if (!info?.path || info.state === 0) continue;
312
+ let storedPath = info.path as string;
313
+ try { storedPath = realpathSync(storedPath); } catch {}
314
+ if (knownPaths.has(storedPath)) return { id: extId, profile: prof };
315
+ }
316
+ }
304
317
  return null;
305
318
  }
306
319
 
@@ -584,7 +597,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
584
597
  };
585
598
 
586
599
  // [1/4] Daemon
587
- console.log("\n[1/4] Setting up serve daemon...");
600
+ console.log("\n[1/4] Checking serve daemon...");
588
601
 
589
602
  // Bind address (persists to ~/.env.local as RECH_HOST).
590
603
  // Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
@@ -617,15 +630,24 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
617
630
  const liveBindUnknown = !!authPing?.ok && !liveBind;
618
631
  const currentBind = liveBind || persistedBind;
619
632
 
633
+ // A healthy daemon already answering on our key needs no reinstall — don't re-prompt for it.
634
+ const daemonHealthy = !!(anonPing && authPing?.ok && !liveBindUnknown);
635
+ // An explicit RECH_HOST override that differs from the live bind is a deliberate rebind request.
636
+ const explicitRebind = !!process.env.RECH_HOST && process.env.RECH_HOST !== currentBind;
637
+
620
638
  // Non-TTY honors explicit process.env.RECH_HOST (shell or merged env stack) — matches the documented `RECH_HOST=0.0.0.0 rech setup` flow.
621
639
  let desiredBind = process.env.RECH_HOST || currentBind;
622
- if (isTTY) {
640
+ // Only prompt to (re)configure the bind when we actually need to set up the daemon. A running
641
+ // daemon is left alone unless the user explicitly asks for a different bind via RECH_HOST.
642
+ if (isTTY && (!daemonHealthy || explicitRebind)) {
623
643
  console.log(`\n Bind address (current: ${currentBind}):`);
624
644
  console.log(` 1. 127.0.0.1 (localhost only)`);
625
645
  console.log(` 2. 0.0.0.0 (all interfaces — HTTP plaintext, trust your network)`);
626
646
  const defaultBindChoice = currentBind === "0.0.0.0" ? "2" : "1";
627
647
  const bindAns = (await ask(` Choice [${defaultBindChoice}]: `, defaultBindChoice)).trim();
628
648
  desiredBind = bindAns === "2" || bindAns === "0.0.0.0" ? "0.0.0.0" : "127.0.0.1";
649
+ } else if (daemonHealthy) {
650
+ console.log(` Daemon already running at ${protocol}://${host}:${port} (bind: ${currentBind}) — skipping daemon setup`);
629
651
  }
630
652
  const bindChanged = desiredBind !== currentBind;
631
653
  const persistedChanged = desiredBind !== persistedBind;
@@ -761,11 +783,26 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
761
783
  console.log(` [agent] Find PLAYWRIGHT_MCP_EXTENSION_TOKEN=... on that page`);
762
784
  console.log(` [agent] Provide the token value on next stdin line:\n`);
763
785
  }
764
- const tokenInput = (await ask(" Paste token: ")).trim();
765
- const token = tokenInput.replace(/^.*?=/, "").trim();
766
- if (!token || token.length < 20) { console.error(" Invalid token (too short)"); return null; }
767
- console.log(" Token accepted");
768
- return { extId, token };
786
+ // Retry on empty/too-short paste — a truncated copy or a stale token shouldn't
787
+ // abort the whole setup. Bounded so a non-TTY agent with exhausted stdin can't spin.
788
+ const maxTries = isTTY ? 5 : 3;
789
+ for (let attempt = 1; attempt <= maxTries; attempt++) {
790
+ const tokenInput = (await ask(" Paste token: ")).trim();
791
+ const token = tokenInput.replace(/^.*?=/, "").trim();
792
+ const retriesLeft = maxTries - attempt;
793
+ if (!token) {
794
+ console.error(` No token entered.${retriesLeft ? " Copy the full PLAYWRIGHT_MCP_EXTENSION_TOKEN value and try again." : ""}`);
795
+ } else if (token.length < 20) {
796
+ console.error(` Token too short (${token.length} chars) — likely truncated when copying.${retriesLeft ? " Re-copy the full value and try again." : ""}`);
797
+ } else {
798
+ console.log(" Token accepted");
799
+ return { extId, token };
800
+ }
801
+ // Non-TTY with no input left: ask() won't block, so stop instead of burning retries on empty reads.
802
+ if (!isTTY && !tokenInput) break;
803
+ }
804
+ console.error(" No valid token provided — aborting");
805
+ return null;
769
806
  }
770
807
 
771
808
  // [2/4] Primary profile
@@ -796,12 +833,16 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
796
833
  const pwdEnvPath = join(process.cwd(), ".env.local");
797
834
  const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
798
835
  const homeEnvPath = join(process.env.HOME!, ".env.local");
836
+ // Show whether each target already exists so it's clear we'll update (merge) vs create.
837
+ const tag = async (p: string) => (await file(p).exists()) ? "exists → will update" : "new file";
838
+ const [pwdTag, pwdRechTag, homeTag] = await Promise.all([tag(pwdEnvPath), tag(pwdRechPath), tag(homeEnvPath)]);
799
839
  const saveChoice = (await ask(
800
- `Save to:\n 1. ${pwdEnvPath} (current dir) [default]\n 2. ${pwdRechPath} (current dir, rechrome-only)\n 3. ${homeEnvPath} (user home)\n 4. Skip (already copied)\n\n Choice [1]: `
840
+ `Save to:\n 1. ${pwdEnvPath} (current dir) [${pwdTag}] [default]\n 2. ${pwdRechPath} (current dir, rechrome-only) [${pwdRechTag}]\n 3. ${homeEnvPath} (user home) [${homeTag}]\n 4. Skip (already copied)\n\n Choice [1]: `
801
841
  )).trim();
802
842
  if (saveChoice !== "4") {
803
843
  const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
804
844
  if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
845
+ const existedBefore = await file(globalEnvPath).exists();
805
846
  const existing = await file(globalEnvPath).text().catch(() => "");
806
847
  const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
807
848
  let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
@@ -809,7 +850,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
809
850
  if (rechIdx >= 0) lines[rechIdx] = newLine;
810
851
  else lines.push(newLine);
811
852
  await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
812
- console.log(`\nSaved to ${globalEnvPath}`);
853
+ console.log(`\n${existedBefore ? "Updated" : "Created"} ${globalEnvPath}`);
813
854
  }
814
855
 
815
856
  // Save primary to token registry
@@ -837,7 +878,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
837
878
  }
838
879
  rl?.close();
839
880
  envWatcher?.close();
840
- console.log(`\nDone! Test with:\n rech eval "() => document.title"`);
881
+ console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
841
882
  }
842
883
 
843
884
  async function status(): Promise<void> {