rechrome 1.12.3 → 1.14.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.
@@ -368,14 +368,33 @@ async function openRelayConnection(mcpRelayUrl, protocolVersion) {
368
368
  }
369
369
  const PLAYWRIGHT_GROUP_TITLE = "Playwright";
370
370
  const PLAYWRIGHT_GROUP_COLOR = "green";
371
+ const PLAYWRIGHT_GROUP_MARK = "🎭";
371
372
  const NON_DEBUGGABLE_SCHEMES = ["chrome:", "edge:", "devtools:"];
372
373
  const CONNECTED_BADGE = { text: "✓", color: "#4CAF50", title: "Connected to Playwright client" };
373
374
  function isNonDebuggableUrl(url) {
374
375
  return !!url && NON_DEBUGGABLE_SCHEMES.some((s) => url.startsWith(s));
375
376
  }
377
+ function urlDomain(url) {
378
+ if (!url)
379
+ return void 0;
380
+ try {
381
+ const u = new URL(url);
382
+ if (u.protocol !== "http:" && u.protocol !== "https:")
383
+ return void 0;
384
+ return u.hostname.replace(/^www\./, "");
385
+ } catch {
386
+ return void 0;
387
+ }
388
+ }
389
+ function groupTitle(clientName, seedUrl) {
390
+ return `${PLAYWRIGHT_GROUP_MARK} ${clientName || urlDomain(seedUrl) || PLAYWRIGHT_GROUP_TITLE}`;
391
+ }
376
392
  async function cleanupStalePlaywrightGroups() {
377
393
  try {
378
- const groups = await chrome.tabGroups.query({ title: PLAYWRIGHT_GROUP_TITLE });
394
+ const groups = (await chrome.tabGroups.query({})).filter((g) => {
395
+ var _a;
396
+ return (_a = g.title) == null ? void 0 : _a.startsWith(PLAYWRIGHT_GROUP_MARK);
397
+ });
379
398
  const tabsPerGroup = await Promise.all(groups.map((g) => chrome.tabs.query({ groupId: g.id })));
380
399
  const tabIds = tabsPerGroup.flat().map((t) => t.id).filter((id) => id !== void 0);
381
400
  if (tabIds.length)
@@ -385,14 +404,16 @@ async function cleanupStalePlaywrightGroups() {
385
404
  }
386
405
  }
387
406
  class ConnectedTabGroup {
388
- constructor(connection, selectedTab) {
407
+ constructor(connection, selectedTab, clientName) {
389
408
  __publicField(this, "_connection");
390
409
  __publicField(this, "_groupId", null);
391
410
  __publicField(this, "_groupTabIds", /* @__PURE__ */ new Set());
392
411
  __publicField(this, "_onTabUpdatedListener");
393
412
  __publicField(this, "_onTabRemovedListener");
413
+ __publicField(this, "_groupTitle");
394
414
  __publicField(this, "onclose");
395
415
  this._connection = connection;
416
+ this._groupTitle = groupTitle(clientName, selectedTab.url);
396
417
  this._connection.onclose = () => this._onConnectionClose();
397
418
  this._connection.ontabattached = (tabId) => this._onTabAttached(tabId);
398
419
  this._connection.ontabdetached = (tabId) => this._onTabDetached(tabId);
@@ -485,7 +506,7 @@ class ConnectedTabGroup {
485
506
  await this._retryOnDrag(async () => {
486
507
  if (this._groupId === null) {
487
508
  this._groupId = await chrome.tabs.group({ tabIds: [tabId] });
488
- await chrome.tabGroups.update(this._groupId, { color: PLAYWRIGHT_GROUP_COLOR, title: PLAYWRIGHT_GROUP_TITLE });
509
+ await chrome.tabGroups.update(this._groupId, { color: PLAYWRIGHT_GROUP_COLOR, title: this._groupTitle });
489
510
  } else {
490
511
  await chrome.tabs.group({ groupId: this._groupId, tabIds: [tabId] });
491
512
  }
@@ -518,8 +539,11 @@ class ConnectedTabGroup {
518
539
  }
519
540
  class PlaywrightExtension {
520
541
  constructor() {
521
- __publicField(this, "_activeGroup");
522
- __publicField(this, "_activeClientName");
542
+ // Multiple concurrent clients can share one Chrome profile — each connection gets
543
+ // its own ConnectedTabGroup (its own Chrome tab group), so a new client no longer
544
+ // evicts existing ones. Tabs stay isolated by per-group _groupId / attachedTabs.
545
+ __publicField(this, "_activeGroups", /* @__PURE__ */ new Set());
546
+ __publicField(this, "_clientNames", /* @__PURE__ */ new Map());
523
547
  __publicField(this, "_pendingConnections", new PendingConnections());
524
548
  // Service worker restarts lose all connection state, so any existing
525
549
  // Playwright groups are stale. Connections wait on this before reconciling.
@@ -530,7 +554,6 @@ class PlaywrightExtension {
530
554
  }
531
555
  // Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
532
556
  _onMessage(message, sender, sendResponse) {
533
- var _a;
534
557
  switch (message.type) {
535
558
  case "connectionRequested":
536
559
  this._pendingConnections.create(sender.tab.id, message.mcpRelayUrl, message.protocolVersion).then(
@@ -541,8 +564,8 @@ class PlaywrightExtension {
541
564
  case "getTabs":
542
565
  this._getTabs().then(
543
566
  (tabs) => {
544
- var _a2;
545
- return sendResponse({ success: true, tabs, currentTabId: (_a2 = sender.tab) == null ? void 0 : _a2.id });
567
+ var _a;
568
+ return sendResponse({ success: true, tabs, currentTabId: (_a = sender.tab) == null ? void 0 : _a.id });
546
569
  },
547
570
  (error) => sendResponse({ success: false, error: error.message })
548
571
  );
@@ -557,8 +580,8 @@ class PlaywrightExtension {
557
580
  }
558
581
  case "getConnectionStatus":
559
582
  sendResponse({
560
- connectedTabIds: ((_a = this._activeGroup) == null ? void 0 : _a.connectedTabIds()) ?? [],
561
- clientName: this._activeClientName
583
+ connectedTabIds: [...this._activeGroups].flatMap((group) => group.connectedTabIds()),
584
+ clientName: [...this._clientNames.values()].filter(Boolean).join(", ") || void 0
562
585
  });
563
586
  return false;
564
587
  case "disconnect":
@@ -576,19 +599,16 @@ class PlaywrightExtension {
576
599
  async _connectTab(selectorTabId, tab, clientName) {
577
600
  try {
578
601
  await this._cleanupPromise;
579
- this._disconnect("Another connection is requested");
580
602
  const connection = await this._pendingConnections.take(selectorTabId);
581
603
  if (!connection)
582
604
  throw new Error("Pending client connection closed");
583
- const group = new ConnectedTabGroup(connection, tab);
605
+ const group = new ConnectedTabGroup(connection, tab, clientName);
584
606
  group.onclose = () => {
585
- if (this._activeGroup === group) {
586
- this._activeGroup = void 0;
587
- this._activeClientName = void 0;
588
- }
607
+ this._activeGroups.delete(group);
608
+ this._clientNames.delete(group);
589
609
  };
590
- this._activeGroup = group;
591
- this._activeClientName = clientName;
610
+ this._activeGroups.add(group);
611
+ this._clientNames.set(group, clientName);
592
612
  await Promise.all([
593
613
  chrome.tabs.update(tab.id, { active: true }),
594
614
  chrome.windows.update(tab.windowId, { focused: true })
@@ -612,13 +632,13 @@ class PlaywrightExtension {
612
632
  active: true
613
633
  });
614
634
  }
615
- // Closes the active group's connection if any. ConnectedTabGroup's onclose
616
- // handles state cleanup (connectedTabIds, badges, reconcile).
635
+ // Closes every active group's connection. ConnectedTabGroup's onclose handles
636
+ // per-group state cleanup (connectedTabIds, badges, reconcile).
617
637
  _disconnect(reason) {
618
- var _a;
619
- (_a = this._activeGroup) == null ? void 0 : _a.close(reason);
620
- this._activeGroup = void 0;
621
- this._activeClientName = void 0;
638
+ for (const group of this._activeGroups)
639
+ group.close(reason);
640
+ this._activeGroups.clear();
641
+ this._clientNames.clear();
622
642
  }
623
643
  }
624
644
  new PlaywrightExtension();
@@ -27,9 +27,10 @@ const ConnectApp = () => {
27
27
  setError(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`);
28
28
  return;
29
29
  }
30
+ let info = "unknown";
30
31
  try {
31
32
  const client = JSON.parse(params.get("client") || "{}");
32
- const info = `${client.name || "unknown"}`;
33
+ info = `${client.name || "unknown"}`;
33
34
  setClientInfo(info);
34
35
  setStatus({
35
36
  type: "connecting",
@@ -60,7 +61,7 @@ const ConnectApp = () => {
60
61
  const expectedToken = getOrCreateAuthToken();
61
62
  const token = params.get("token");
62
63
  if (token === expectedToken) {
63
- await handleConnectToTab();
64
+ await handleConnectToTab(void 0, info);
64
65
  return;
65
66
  }
66
67
  if (token) {
@@ -86,26 +87,26 @@ const ConnectApp = () => {
86
87
  else
87
88
  setStatus({ type: "error", message: "Failed to load tabs: " + response.error });
88
89
  }, []);
89
- const handleConnectToTab = reactExports.useCallback(async (tab) => {
90
+ const handleConnectToTab = reactExports.useCallback(async (tab, clientName = clientInfo) => {
90
91
  setShowTabList(false);
91
92
  try {
92
93
  const response = await chrome.runtime.sendMessage({
93
94
  type: "connectToTab",
94
95
  tab,
95
- clientName: clientInfo
96
+ clientName
96
97
  });
97
98
  if (response == null ? void 0 : response.success) {
98
- setStatus({ type: "connected", message: `"${clientInfo}" connected.` });
99
+ setStatus({ type: "connected", message: `"${clientName}" connected.` });
99
100
  } else {
100
101
  setStatus({
101
102
  type: "error",
102
- message: (response == null ? void 0 : response.error) || `"${clientInfo}" failed to connect.`
103
+ message: (response == null ? void 0 : response.error) || `"${clientName}" failed to connect.`
103
104
  });
104
105
  }
105
106
  } catch (e) {
106
107
  setStatus({
107
108
  type: "error",
108
- message: `"${clientInfo}" failed to connect: ${e}`
109
+ message: `"${clientName}" failed to connect: ${e}`
109
110
  });
110
111
  }
111
112
  }, [clientInfo]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.12.3",
3
+ "version": "1.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -597,7 +597,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
597
597
  };
598
598
 
599
599
  // [1/4] Daemon
600
- console.log("\n[1/4] Setting up serve daemon...");
600
+ console.log("\n[1/4] Checking serve daemon...");
601
601
 
602
602
  // Bind address (persists to ~/.env.local as RECH_HOST).
603
603
  // Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
@@ -630,15 +630,24 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
630
630
  const liveBindUnknown = !!authPing?.ok && !liveBind;
631
631
  const currentBind = liveBind || persistedBind;
632
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
+
633
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.
634
639
  let desiredBind = process.env.RECH_HOST || currentBind;
635
- 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)) {
636
643
  console.log(`\n Bind address (current: ${currentBind}):`);
637
644
  console.log(` 1. 127.0.0.1 (localhost only)`);
638
645
  console.log(` 2. 0.0.0.0 (all interfaces — HTTP plaintext, trust your network)`);
639
646
  const defaultBindChoice = currentBind === "0.0.0.0" ? "2" : "1";
640
647
  const bindAns = (await ask(` Choice [${defaultBindChoice}]: `, defaultBindChoice)).trim();
641
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`);
642
651
  }
643
652
  const bindChanged = desiredBind !== currentBind;
644
653
  const persistedChanged = desiredBind !== persistedBind;
@@ -774,11 +783,26 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
774
783
  console.log(` [agent] Find PLAYWRIGHT_MCP_EXTENSION_TOKEN=... on that page`);
775
784
  console.log(` [agent] Provide the token value on next stdin line:\n`);
776
785
  }
777
- const tokenInput = (await ask(" Paste token: ")).trim();
778
- const token = tokenInput.replace(/^.*?=/, "").trim();
779
- if (!token || token.length < 20) { console.error(" Invalid token (too short)"); return null; }
780
- console.log(" Token accepted");
781
- 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;
782
806
  }
783
807
 
784
808
  // [2/4] Primary profile
@@ -809,12 +833,16 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
809
833
  const pwdEnvPath = join(process.cwd(), ".env.local");
810
834
  const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
811
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)]);
812
839
  const saveChoice = (await ask(
813
- `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]: `
814
841
  )).trim();
815
842
  if (saveChoice !== "4") {
816
843
  const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
817
844
  if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
845
+ const existedBefore = await file(globalEnvPath).exists();
818
846
  const existing = await file(globalEnvPath).text().catch(() => "");
819
847
  const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
820
848
  let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
@@ -822,7 +850,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
822
850
  if (rechIdx >= 0) lines[rechIdx] = newLine;
823
851
  else lines.push(newLine);
824
852
  await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
825
- console.log(`\nSaved to ${globalEnvPath}`);
853
+ console.log(`\n${existedBefore ? "Updated" : "Created"} ${globalEnvPath}`);
826
854
  }
827
855
 
828
856
  // Save primary to token registry
@@ -850,7 +878,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
850
878
  }
851
879
  rl?.close();
852
880
  envWatcher?.close();
853
- console.log(`\nDone! Test with:\n rech eval "() => document.title"`);
881
+ console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
854
882
  }
855
883
 
856
884
  async function status(): Promise<void> {
package/rech.ts CHANGED
@@ -597,7 +597,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
597
597
  };
598
598
 
599
599
  // [1/4] Daemon
600
- console.log("\n[1/4] Setting up serve daemon...");
600
+ console.log("\n[1/4] Checking serve daemon...");
601
601
 
602
602
  // Bind address (persists to ~/.env.local as RECH_HOST).
603
603
  // Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
@@ -630,15 +630,24 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
630
630
  const liveBindUnknown = !!authPing?.ok && !liveBind;
631
631
  const currentBind = liveBind || persistedBind;
632
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
+
633
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.
634
639
  let desiredBind = process.env.RECH_HOST || currentBind;
635
- 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)) {
636
643
  console.log(`\n Bind address (current: ${currentBind}):`);
637
644
  console.log(` 1. 127.0.0.1 (localhost only)`);
638
645
  console.log(` 2. 0.0.0.0 (all interfaces — HTTP plaintext, trust your network)`);
639
646
  const defaultBindChoice = currentBind === "0.0.0.0" ? "2" : "1";
640
647
  const bindAns = (await ask(` Choice [${defaultBindChoice}]: `, defaultBindChoice)).trim();
641
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`);
642
651
  }
643
652
  const bindChanged = desiredBind !== currentBind;
644
653
  const persistedChanged = desiredBind !== persistedBind;
@@ -774,11 +783,26 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
774
783
  console.log(` [agent] Find PLAYWRIGHT_MCP_EXTENSION_TOKEN=... on that page`);
775
784
  console.log(` [agent] Provide the token value on next stdin line:\n`);
776
785
  }
777
- const tokenInput = (await ask(" Paste token: ")).trim();
778
- const token = tokenInput.replace(/^.*?=/, "").trim();
779
- if (!token || token.length < 20) { console.error(" Invalid token (too short)"); return null; }
780
- console.log(" Token accepted");
781
- 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;
782
806
  }
783
807
 
784
808
  // [2/4] Primary profile
@@ -809,12 +833,16 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
809
833
  const pwdEnvPath = join(process.cwd(), ".env.local");
810
834
  const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
811
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)]);
812
839
  const saveChoice = (await ask(
813
- `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]: `
814
841
  )).trim();
815
842
  if (saveChoice !== "4") {
816
843
  const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
817
844
  if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
845
+ const existedBefore = await file(globalEnvPath).exists();
818
846
  const existing = await file(globalEnvPath).text().catch(() => "");
819
847
  const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
820
848
  let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
@@ -822,7 +850,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
822
850
  if (rechIdx >= 0) lines[rechIdx] = newLine;
823
851
  else lines.push(newLine);
824
852
  await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
825
- console.log(`\nSaved to ${globalEnvPath}`);
853
+ console.log(`\n${existedBefore ? "Updated" : "Created"} ${globalEnvPath}`);
826
854
  }
827
855
 
828
856
  // Save primary to token registry
@@ -850,7 +878,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
850
878
  }
851
879
  rl?.close();
852
880
  envWatcher?.close();
853
- console.log(`\nDone! Test with:\n rech eval "() => document.title"`);
881
+ console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
854
882
  }
855
883
 
856
884
  async function status(): Promise<void> {
package/serve.js CHANGED
@@ -14,6 +14,20 @@ import {
14
14
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
15
15
  const CERT_RENEW_THRESHOLD_DAYS = 7;
16
16
 
17
+ // Short, human-friendly label for a client identity, used as the Chrome tab-group
18
+ // name so concurrent sessions on one profile are distinguishable.
19
+ // gitUrl ".../owner/repo/tree/branch" -> "repo:branch"; "host:/path/to/dir" -> "dir";
20
+ // bare host/IP -> as-is. Strips a trailing "@profile" (email) suffix first.
21
+ function shortClientLabel(raw: string): string {
22
+ if (!raw) return raw;
23
+ const baseId = raw.includes("@") ? raw.slice(0, raw.indexOf("@")) : raw;
24
+ const git = baseId.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
25
+ if (git) return git[2] ? `${git[1]}:${git[2]}` : git[1];
26
+ const hostCwd = baseId.match(/^[^:]+:(.+)$/);
27
+ if (hostCwd) return hostCwd[1].split("/").filter(Boolean).pop() || baseId;
28
+ return baseId;
29
+ }
30
+
17
31
  async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
18
32
  const certContent = await file(certPath).text().catch(() => null);
19
33
  if (!certContent) return false;
@@ -233,7 +247,7 @@ export async function serve() {
233
247
  TMPDIR: process.env.TMPDIR,
234
248
  DISPLAY: process.env.DISPLAY,
235
249
  XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
236
- ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
250
+ ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
237
251
  ...passthroughEnv,
238
252
  // Enable extension bridge when credentials are present
239
253
  ...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN
package/serve.ts CHANGED
@@ -14,6 +14,20 @@ import {
14
14
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
15
15
  const CERT_RENEW_THRESHOLD_DAYS = 7;
16
16
 
17
+ // Short, human-friendly label for a client identity, used as the Chrome tab-group
18
+ // name so concurrent sessions on one profile are distinguishable.
19
+ // gitUrl ".../owner/repo/tree/branch" -> "repo:branch"; "host:/path/to/dir" -> "dir";
20
+ // bare host/IP -> as-is. Strips a trailing "@profile" (email) suffix first.
21
+ function shortClientLabel(raw: string): string {
22
+ if (!raw) return raw;
23
+ const baseId = raw.includes("@") ? raw.slice(0, raw.indexOf("@")) : raw;
24
+ const git = baseId.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
25
+ if (git) return git[2] ? `${git[1]}:${git[2]}` : git[1];
26
+ const hostCwd = baseId.match(/^[^:]+:(.+)$/);
27
+ if (hostCwd) return hostCwd[1].split("/").filter(Boolean).pop() || baseId;
28
+ return baseId;
29
+ }
30
+
17
31
  async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
18
32
  const certContent = await file(certPath).text().catch(() => null);
19
33
  if (!certContent) return false;
@@ -233,7 +247,7 @@ export async function serve() {
233
247
  TMPDIR: process.env.TMPDIR,
234
248
  DISPLAY: process.env.DISPLAY,
235
249
  XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
236
- ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
250
+ ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
237
251
  ...passthroughEnv,
238
252
  // Enable extension bridge when credentials are present
239
253
  ...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN