rechrome 1.23.4 → 1.24.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.
Files changed (5) hide show
  1. package/package.json +1 -1
  2. package/rech.js +186 -10
  3. package/rech.ts +186 -10
  4. package/serve.js +37 -0
  5. package/serve.ts +37 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.23.4",
3
+ "version": "1.24.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -400,6 +400,59 @@ export function validateChromeProfileSelector(selector: string): void {
400
400
  );
401
401
  }
402
402
 
403
+ export async function resolveGlobalProfile(
404
+ registry: Record<string, TokenEntry>,
405
+ chromeProfiles: Record<string, ChromeProfileInfo> | null,
406
+ selector: string,
407
+ ): Promise<{ email: string; entry: TokenEntry }> {
408
+ const value = selector.trim();
409
+ if (!value) throw new Error("--profile requires a non-empty value");
410
+
411
+ const registryKey = Object.keys(registry).find(k => k.toLowerCase() === value.toLowerCase());
412
+ if (registryKey) return { email: registryKey, entry: registry[registryKey] };
413
+
414
+ if (!chromeProfiles) {
415
+ throw new Error(
416
+ `--profile "${value}" does not match any registered email, and Chrome profiles are not accessible. ` +
417
+ `Run \`rech setup --profile "${value}"\` to register this profile.`,
418
+ );
419
+ }
420
+
421
+ const profiles = Object.entries(chromeProfiles);
422
+ let match: [string, ChromeProfileInfo] | null;
423
+ try {
424
+ match = resolveChromeProfileSelector(profiles, value);
425
+ } catch (err) {
426
+ throw err;
427
+ }
428
+
429
+ if (!match) {
430
+ throw new Error(
431
+ `--profile "${value}" does not match any Chrome profile. ` +
432
+ `See available profiles with \`rech profiles\`.`,
433
+ );
434
+ }
435
+
436
+ const [dir, info] = match;
437
+ const email = info.user_name;
438
+ if (!email) {
439
+ throw new Error(
440
+ `Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
441
+ `Run \`rech setup --profile "${value}"\` to register it.`,
442
+ );
443
+ }
444
+
445
+ const entry = registry[email];
446
+ if (!entry) {
447
+ throw new Error(
448
+ `Profile "${email}" (${dir}) is not registered. ` +
449
+ `Run \`rech setup --profile "${value}"\` to register it.`,
450
+ );
451
+ }
452
+
453
+ return { email, entry };
454
+ }
455
+
403
456
  async function findChromeUserDataDir(): Promise<string | null> {
404
457
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
405
458
  if (!(await file(statePath).exists())) continue;
@@ -545,7 +598,10 @@ async function callServe(
545
598
  // and run() has already done so for its log line. Recomputing here would double those git
546
599
  // spawns (and, on Windows, the console-window flashes) on every `rech open`.
547
600
  const identity = precomputedIdentity ?? await getClientIdentity();
548
- const effectiveProfile = resolveEffectiveProfile(profileDirectory);
601
+ // A global `--profile` override must win for the session key too: the daemon hashes
602
+ // identity.profile into the session id, so without this, `rech --profile other open` would
603
+ // reuse the default profile's session (and its browser) instead of opening its own.
604
+ const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
549
605
  if (effectiveProfile) identity.profile = effectiveProfile;
550
606
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
551
607
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
@@ -588,12 +644,43 @@ export function normalizeCommandArgs(args: string[]): string[] {
588
644
  return normalized;
589
645
  }
590
646
 
591
- async function run(url: string, args: string[]) {
647
+ // Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
648
+ // Only flags before the first positional (the playwright subcommand) are rech globals — a
649
+ // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
650
+ // `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
651
+ // missing value; accepts multiple occurrences (last one wins).
652
+ export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
653
+ const rest = [...args];
654
+ let selector: string | undefined;
655
+ for (let i = 0; i < rest.length && rest[i].startsWith("-"); i++) {
656
+ const a = rest[i];
657
+ if (a === "--profile") {
658
+ const value = rest[i + 1];
659
+ if (!value || value.startsWith("--"))
660
+ throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
661
+ selector = value;
662
+ rest.splice(i, 2);
663
+ i--;
664
+ continue;
665
+ }
666
+ if (a.startsWith("--profile=")) {
667
+ const value = a.slice("--profile=".length);
668
+ if (!value) throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
669
+ selector = value;
670
+ rest.splice(i, 1);
671
+ i--;
672
+ continue;
673
+ }
674
+ }
675
+ return { args: rest, selector };
676
+ }
677
+
678
+ async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
592
679
  // Match the underlying CLI's command names while accepting the short forms humans
593
680
  // naturally try. Keep this client-side so old and new serve daemons behave alike.
594
681
  args = normalizeCommandArgs(args);
595
682
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
596
- const effectiveProfile = resolveEffectiveProfile(profileDirectory);
683
+ const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
597
684
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
598
685
  const identity = await getClientIdentity();
599
686
  const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
@@ -602,18 +689,19 @@ async function run(url: string, args: string[]) {
602
689
  );
603
690
 
604
691
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
605
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
692
+ const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
693
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
606
694
 
607
695
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
608
696
  if (existingSession && isOpenWithUrl) {
609
- return run(url, ["goto", ...args.slice(1)]);
697
+ return run(url, ["goto", ...args.slice(1)], overrideEnv);
610
698
  }
611
699
 
612
700
  if (existingSession)
613
701
  console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
614
702
  if (stderr) {
615
703
  if (stderr.includes('Extension connection timeout')) {
616
- const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
704
+ const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
617
705
  const last = hasToken
618
706
  ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
619
707
  : ` -> extension[not installed] (run: rech setup)`;
@@ -923,12 +1011,51 @@ function findTrayBinary(): string | undefined {
923
1011
  return Bun.which(`rechrome-tray${ext}`) ?? undefined;
924
1012
  }
925
1013
 
1014
+ // Verify a PID is actually a rechrome-tray process, not an unrelated process that
1015
+ // happened to reuse the pid after the real tray died. POSIX only (`ps` isn't on stock
1016
+ // Windows); there we fall back to trusting the liveness probe alone (best-effort).
1017
+ function isPidRechromeTray(pid: number): boolean {
1018
+ if (IS_WINDOWS) return true;
1019
+ try {
1020
+ const p = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1021
+ return (p.stdout?.toString() ?? "").trim().includes("rechrome-tray");
1022
+ } catch {
1023
+ return false;
1024
+ }
1025
+ }
1026
+
1027
+ // PIDs of live rechrome-tray processes (POSIX). Catches untracked trays — ones launched
1028
+ // outside `rech tray show` that never touch the pidfile — so a singleton guard can't
1029
+ // spawn a second icon on top of them. Empty on Windows (no `ps`), where we rely on the
1030
+ // pidfile liveness check alone.
1031
+ function listTrayPids(): number[] {
1032
+ if (IS_WINDOWS) return [];
1033
+ try {
1034
+ const p = Bun.spawnSync(["ps", "-axo", "pid=,comm="], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1035
+ const out = p.stdout?.toString() ?? "";
1036
+ const pids: number[] = [];
1037
+ for (const line of out.split("\n")) {
1038
+ const m = line.trim().match(/^(\d+)\s+(.+)$/);
1039
+ // macOS reports the full launch path in `comm`, so match the basename via a
1040
+ // trailing "/rechrome-tray" or an exact bare name (Linux truncates to the name).
1041
+ if (m && Number(m[1]) !== process.pid && m[2].includes("rechrome-tray")) pids.push(Number(m[1]));
1042
+ }
1043
+ return pids;
1044
+ } catch {
1045
+ return [];
1046
+ }
1047
+ }
1048
+
926
1049
  function isTrayRunning(): boolean {
1050
+ // Any live tray (tracked by the pidfile or not) means "already running" — the pidfile
1051
+ // is a single slot that only ever records the most-recent spawn, so it cannot see
1052
+ // earlier/untracked instances on its own.
1053
+ if (listTrayPids().length > 0) return true;
927
1054
  try {
928
1055
  const pid = parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10);
929
1056
  if (!Number.isFinite(pid)) return false;
930
1057
  process.kill(pid, 0); // signal 0 = liveness probe, doesn't actually signal
931
- return true;
1058
+ return isPidRechromeTray(pid);
932
1059
  } catch {
933
1060
  return false;
934
1061
  }
@@ -958,7 +1085,12 @@ async function startTray({ quiet = false }: { quiet?: boolean } = {}): Promise<v
958
1085
  }
959
1086
 
960
1087
  function stopTray(): void {
961
- if (!isTrayRunning()) { console.log("tray: not running."); return; }
1088
+ // Kill every live tray (tracked or untracked), then drop the pidfile. A singleton
1089
+ // tray should never have more than one instance, but a crashed/overwritten pidfile
1090
+ // can leave orphans the single-slot pidfile no longer points at — reap them too.
1091
+ const pids = listTrayPids();
1092
+ if (pids.length === 0 && !isTrayRunning()) { console.log("tray: not running."); return; }
1093
+ for (const pid of pids) { try { process.kill(pid); } catch {} }
962
1094
  try { process.kill(parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10)); } catch {}
963
1095
  try { unlinkSync(TRAY_PID_FILE); } catch {}
964
1096
  console.log("tray: stopped. Run `rech tray show` to restore.");
@@ -1623,6 +1755,14 @@ function printHelp(): void {
1623
1755
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1624
1756
 
1625
1757
  Usage:
1758
+ rech [--profile <email|name|folder>] <playwright-args...>
1759
+ Run Playwright CLI command with the given registered
1760
+ Chrome profile. --profile selects the profile by exact
1761
+ registered email (e.g. you@gmail.com), exact Chrome
1762
+ profile name, or exact profile folder name. The profile
1763
+ must already be registered (see \`rech setup\`). Place
1764
+ --profile before the playwright subcommand. Requires
1765
+ ${ENV_KEY}.
1626
1766
  rech setup [--profile <email|name|folder>] [--token <tok>]
1627
1767
  First-time setup: daemon + Chrome extension + config
1628
1768
  --profile selects the Chrome profile non-interactively.
@@ -1659,13 +1799,14 @@ Environment:
1659
1799
  Examples:
1660
1800
  rech setup
1661
1801
  rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1802
+ rech --profile you@gmail.com open https://example.com
1662
1803
  rech eval "() => document.title"
1663
1804
  rech open https://example.com
1664
1805
  rech screenshot`);
1665
1806
  }
1666
1807
 
1667
1808
  if (import.meta.main) {
1668
- const args = process.argv.slice(2);
1809
+ let args = process.argv.slice(2);
1669
1810
  const cmd = args[0]?.toLowerCase();
1670
1811
 
1671
1812
  if (cmd === "serve") {
@@ -1723,6 +1864,41 @@ if (import.meta.main) {
1723
1864
  printHelp();
1724
1865
  process.exit(1);
1725
1866
  }
1867
+ // --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
1868
+ // the leading-flags-only rule that protects the forwarded CLI's own --profile).
1869
+ let profileSelector: string | undefined;
1870
+ let overrideEnv: Record<string, string> | undefined;
1871
+ try {
1872
+ const extracted = extractGlobalProfileArg(args);
1873
+ profileSelector = extracted.selector;
1874
+ args = extracted.args;
1875
+ } catch (err) {
1876
+ console.error(err instanceof Error ? err.message : String(err));
1877
+ envWatcher?.close();
1878
+ process.exit(1);
1879
+ }
1880
+ if (profileSelector !== undefined) {
1881
+ try {
1882
+ const registry = await readTokenRegistry();
1883
+ const cache = await readChromeProfileCache();
1884
+ const resolved = await resolveGlobalProfile(registry, cache, profileSelector);
1885
+ // Use the registry key (email, or the managed profile name) as the profile identity:
1886
+ // the daemon already resolves email/name → profile dir for the default URL-param path,
1887
+ // so this keeps `--profile <email>` on the SAME session as the default path and only
1888
+ // opens a separate session when the profile really differs.
1889
+ overrideEnv = {
1890
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
1891
+ PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
1892
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
1893
+ };
1894
+ if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
1895
+ if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;
1896
+ } catch (err) {
1897
+ console.error(err instanceof Error ? err.message : String(err));
1898
+ envWatcher?.close();
1899
+ process.exit(1);
1900
+ }
1901
+ }
1726
1902
  // --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
1727
1903
  // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
1728
1904
  // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.js), so an
@@ -1732,7 +1908,7 @@ if (import.meta.main) {
1732
1908
  args.splice(isolateIdx, 1);
1733
1909
  args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
1734
1910
  }
1735
- await run(url, args);
1911
+ await run(url, args, overrideEnv);
1736
1912
  envWatcher?.close();
1737
1913
  }
1738
1914
  }
package/rech.ts CHANGED
@@ -400,6 +400,59 @@ export function validateChromeProfileSelector(selector: string): void {
400
400
  );
401
401
  }
402
402
 
403
+ export async function resolveGlobalProfile(
404
+ registry: Record<string, TokenEntry>,
405
+ chromeProfiles: Record<string, ChromeProfileInfo> | null,
406
+ selector: string,
407
+ ): Promise<{ email: string; entry: TokenEntry }> {
408
+ const value = selector.trim();
409
+ if (!value) throw new Error("--profile requires a non-empty value");
410
+
411
+ const registryKey = Object.keys(registry).find(k => k.toLowerCase() === value.toLowerCase());
412
+ if (registryKey) return { email: registryKey, entry: registry[registryKey] };
413
+
414
+ if (!chromeProfiles) {
415
+ throw new Error(
416
+ `--profile "${value}" does not match any registered email, and Chrome profiles are not accessible. ` +
417
+ `Run \`rech setup --profile "${value}"\` to register this profile.`,
418
+ );
419
+ }
420
+
421
+ const profiles = Object.entries(chromeProfiles);
422
+ let match: [string, ChromeProfileInfo] | null;
423
+ try {
424
+ match = resolveChromeProfileSelector(profiles, value);
425
+ } catch (err) {
426
+ throw err;
427
+ }
428
+
429
+ if (!match) {
430
+ throw new Error(
431
+ `--profile "${value}" does not match any Chrome profile. ` +
432
+ `See available profiles with \`rech profiles\`.`,
433
+ );
434
+ }
435
+
436
+ const [dir, info] = match;
437
+ const email = info.user_name;
438
+ if (!email) {
439
+ throw new Error(
440
+ `Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
441
+ `Run \`rech setup --profile "${value}"\` to register it.`,
442
+ );
443
+ }
444
+
445
+ const entry = registry[email];
446
+ if (!entry) {
447
+ throw new Error(
448
+ `Profile "${email}" (${dir}) is not registered. ` +
449
+ `Run \`rech setup --profile "${value}"\` to register it.`,
450
+ );
451
+ }
452
+
453
+ return { email, entry };
454
+ }
455
+
403
456
  async function findChromeUserDataDir(): Promise<string | null> {
404
457
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
405
458
  if (!(await file(statePath).exists())) continue;
@@ -545,7 +598,10 @@ async function callServe(
545
598
  // and run() has already done so for its log line. Recomputing here would double those git
546
599
  // spawns (and, on Windows, the console-window flashes) on every `rech open`.
547
600
  const identity = precomputedIdentity ?? await getClientIdentity();
548
- const effectiveProfile = resolveEffectiveProfile(profileDirectory);
601
+ // A global `--profile` override must win for the session key too: the daemon hashes
602
+ // identity.profile into the session id, so without this, `rech --profile other open` would
603
+ // reuse the default profile's session (and its browser) instead of opening its own.
604
+ const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
549
605
  if (effectiveProfile) identity.profile = effectiveProfile;
550
606
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
551
607
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
@@ -588,12 +644,43 @@ export function normalizeCommandArgs(args: string[]): string[] {
588
644
  return normalized;
589
645
  }
590
646
 
591
- async function run(url: string, args: string[]) {
647
+ // Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
648
+ // Only flags before the first positional (the playwright subcommand) are rech globals — a
649
+ // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
650
+ // `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
651
+ // missing value; accepts multiple occurrences (last one wins).
652
+ export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
653
+ const rest = [...args];
654
+ let selector: string | undefined;
655
+ for (let i = 0; i < rest.length && rest[i].startsWith("-"); i++) {
656
+ const a = rest[i];
657
+ if (a === "--profile") {
658
+ const value = rest[i + 1];
659
+ if (!value || value.startsWith("--"))
660
+ throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
661
+ selector = value;
662
+ rest.splice(i, 2);
663
+ i--;
664
+ continue;
665
+ }
666
+ if (a.startsWith("--profile=")) {
667
+ const value = a.slice("--profile=".length);
668
+ if (!value) throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
669
+ selector = value;
670
+ rest.splice(i, 1);
671
+ i--;
672
+ continue;
673
+ }
674
+ }
675
+ return { args: rest, selector };
676
+ }
677
+
678
+ async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
592
679
  // Match the underlying CLI's command names while accepting the short forms humans
593
680
  // naturally try. Keep this client-side so old and new serve daemons behave alike.
594
681
  args = normalizeCommandArgs(args);
595
682
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
596
- const effectiveProfile = resolveEffectiveProfile(profileDirectory);
683
+ const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
597
684
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
598
685
  const identity = await getClientIdentity();
599
686
  const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
@@ -602,18 +689,19 @@ async function run(url: string, args: string[]) {
602
689
  );
603
690
 
604
691
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
605
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
692
+ const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
693
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
606
694
 
607
695
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
608
696
  if (existingSession && isOpenWithUrl) {
609
- return run(url, ["goto", ...args.slice(1)]);
697
+ return run(url, ["goto", ...args.slice(1)], overrideEnv);
610
698
  }
611
699
 
612
700
  if (existingSession)
613
701
  console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
614
702
  if (stderr) {
615
703
  if (stderr.includes('Extension connection timeout')) {
616
- const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
704
+ const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
617
705
  const last = hasToken
618
706
  ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
619
707
  : ` -> extension[not installed] (run: rech setup)`;
@@ -923,12 +1011,51 @@ function findTrayBinary(): string | undefined {
923
1011
  return Bun.which(`rechrome-tray${ext}`) ?? undefined;
924
1012
  }
925
1013
 
1014
+ // Verify a PID is actually a rechrome-tray process, not an unrelated process that
1015
+ // happened to reuse the pid after the real tray died. POSIX only (`ps` isn't on stock
1016
+ // Windows); there we fall back to trusting the liveness probe alone (best-effort).
1017
+ function isPidRechromeTray(pid: number): boolean {
1018
+ if (IS_WINDOWS) return true;
1019
+ try {
1020
+ const p = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1021
+ return (p.stdout?.toString() ?? "").trim().includes("rechrome-tray");
1022
+ } catch {
1023
+ return false;
1024
+ }
1025
+ }
1026
+
1027
+ // PIDs of live rechrome-tray processes (POSIX). Catches untracked trays — ones launched
1028
+ // outside `rech tray show` that never touch the pidfile — so a singleton guard can't
1029
+ // spawn a second icon on top of them. Empty on Windows (no `ps`), where we rely on the
1030
+ // pidfile liveness check alone.
1031
+ function listTrayPids(): number[] {
1032
+ if (IS_WINDOWS) return [];
1033
+ try {
1034
+ const p = Bun.spawnSync(["ps", "-axo", "pid=,comm="], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1035
+ const out = p.stdout?.toString() ?? "";
1036
+ const pids: number[] = [];
1037
+ for (const line of out.split("\n")) {
1038
+ const m = line.trim().match(/^(\d+)\s+(.+)$/);
1039
+ // macOS reports the full launch path in `comm`, so match the basename via a
1040
+ // trailing "/rechrome-tray" or an exact bare name (Linux truncates to the name).
1041
+ if (m && Number(m[1]) !== process.pid && m[2].includes("rechrome-tray")) pids.push(Number(m[1]));
1042
+ }
1043
+ return pids;
1044
+ } catch {
1045
+ return [];
1046
+ }
1047
+ }
1048
+
926
1049
  function isTrayRunning(): boolean {
1050
+ // Any live tray (tracked by the pidfile or not) means "already running" — the pidfile
1051
+ // is a single slot that only ever records the most-recent spawn, so it cannot see
1052
+ // earlier/untracked instances on its own.
1053
+ if (listTrayPids().length > 0) return true;
927
1054
  try {
928
1055
  const pid = parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10);
929
1056
  if (!Number.isFinite(pid)) return false;
930
1057
  process.kill(pid, 0); // signal 0 = liveness probe, doesn't actually signal
931
- return true;
1058
+ return isPidRechromeTray(pid);
932
1059
  } catch {
933
1060
  return false;
934
1061
  }
@@ -958,7 +1085,12 @@ async function startTray({ quiet = false }: { quiet?: boolean } = {}): Promise<v
958
1085
  }
959
1086
 
960
1087
  function stopTray(): void {
961
- if (!isTrayRunning()) { console.log("tray: not running."); return; }
1088
+ // Kill every live tray (tracked or untracked), then drop the pidfile. A singleton
1089
+ // tray should never have more than one instance, but a crashed/overwritten pidfile
1090
+ // can leave orphans the single-slot pidfile no longer points at — reap them too.
1091
+ const pids = listTrayPids();
1092
+ if (pids.length === 0 && !isTrayRunning()) { console.log("tray: not running."); return; }
1093
+ for (const pid of pids) { try { process.kill(pid); } catch {} }
962
1094
  try { process.kill(parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10)); } catch {}
963
1095
  try { unlinkSync(TRAY_PID_FILE); } catch {}
964
1096
  console.log("tray: stopped. Run `rech tray show` to restore.");
@@ -1623,6 +1755,14 @@ function printHelp(): void {
1623
1755
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1624
1756
 
1625
1757
  Usage:
1758
+ rech [--profile <email|name|folder>] <playwright-args...>
1759
+ Run Playwright CLI command with the given registered
1760
+ Chrome profile. --profile selects the profile by exact
1761
+ registered email (e.g. you@gmail.com), exact Chrome
1762
+ profile name, or exact profile folder name. The profile
1763
+ must already be registered (see \`rech setup\`). Place
1764
+ --profile before the playwright subcommand. Requires
1765
+ ${ENV_KEY}.
1626
1766
  rech setup [--profile <email|name|folder>] [--token <tok>]
1627
1767
  First-time setup: daemon + Chrome extension + config
1628
1768
  --profile selects the Chrome profile non-interactively.
@@ -1659,13 +1799,14 @@ Environment:
1659
1799
  Examples:
1660
1800
  rech setup
1661
1801
  rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1802
+ rech --profile you@gmail.com open https://example.com
1662
1803
  rech eval "() => document.title"
1663
1804
  rech open https://example.com
1664
1805
  rech screenshot`);
1665
1806
  }
1666
1807
 
1667
1808
  if (import.meta.main) {
1668
- const args = process.argv.slice(2);
1809
+ let args = process.argv.slice(2);
1669
1810
  const cmd = args[0]?.toLowerCase();
1670
1811
 
1671
1812
  if (cmd === "serve") {
@@ -1723,6 +1864,41 @@ if (import.meta.main) {
1723
1864
  printHelp();
1724
1865
  process.exit(1);
1725
1866
  }
1867
+ // --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
1868
+ // the leading-flags-only rule that protects the forwarded CLI's own --profile).
1869
+ let profileSelector: string | undefined;
1870
+ let overrideEnv: Record<string, string> | undefined;
1871
+ try {
1872
+ const extracted = extractGlobalProfileArg(args);
1873
+ profileSelector = extracted.selector;
1874
+ args = extracted.args;
1875
+ } catch (err) {
1876
+ console.error(err instanceof Error ? err.message : String(err));
1877
+ envWatcher?.close();
1878
+ process.exit(1);
1879
+ }
1880
+ if (profileSelector !== undefined) {
1881
+ try {
1882
+ const registry = await readTokenRegistry();
1883
+ const cache = await readChromeProfileCache();
1884
+ const resolved = await resolveGlobalProfile(registry, cache, profileSelector);
1885
+ // Use the registry key (email, or the managed profile name) as the profile identity:
1886
+ // the daemon already resolves email/name → profile dir for the default URL-param path,
1887
+ // so this keeps `--profile <email>` on the SAME session as the default path and only
1888
+ // opens a separate session when the profile really differs.
1889
+ overrideEnv = {
1890
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
1891
+ PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
1892
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
1893
+ };
1894
+ if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
1895
+ if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;
1896
+ } catch (err) {
1897
+ console.error(err instanceof Error ? err.message : String(err));
1898
+ envWatcher?.close();
1899
+ process.exit(1);
1900
+ }
1901
+ }
1726
1902
  // --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
1727
1903
  // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
1728
1904
  // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.ts), so an
@@ -1732,7 +1908,7 @@ if (import.meta.main) {
1732
1908
  args.splice(isolateIdx, 1);
1733
1909
  args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
1734
1910
  }
1735
- await run(url, args);
1911
+ await run(url, args, overrideEnv);
1736
1912
  envWatcher?.close();
1737
1913
  }
1738
1914
  }
package/serve.js CHANGED
@@ -278,6 +278,27 @@ async function freeStalePort(port: number): Promise<void> {
278
278
  await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
279
279
  }
280
280
 
281
+ // --- Foreground/orphan self-exit ---------------------------------------------------
282
+ // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
283
+ // process-manager safety net: when the agent that spawned it exits, the OS re-parents
284
+ // the orphan to init (ppid 1) and it lives forever — the resource leak behind
285
+ // PERFORMANCE-EVENT.md. The managed daemon (oxmgr `--restart always`) keeps the
286
+ // process as its own child, so its ppid is stable and non-1 and it is never flagged.
287
+ // Once a serve is orphaned AND has had no real /run for the idle timeout, it exits so
288
+ // the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
289
+ // and would otherwise keep an orphan alive forever).
290
+ const ORPHAN_POLL_INTERVAL_MS = 15_000;
291
+ const ORPHAN_IDLE_EXIT_MS = Number(process.env.RECH_SERVE_IDLE_TIMEOUT_MS) || 5 * 60_000;
292
+
293
+ // Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
294
+ export function shouldExitOrphanedServe(opts: {
295
+ orphaned: boolean;
296
+ idleMs: number;
297
+ idleTimeoutMs: number;
298
+ }): boolean {
299
+ return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
300
+ }
301
+
281
302
  export async function serve() {
282
303
  const url = await getOrCreateUrl();
283
304
  const { key, port } = parseUrl(url);
@@ -285,6 +306,21 @@ export async function serve() {
285
306
  const workDir = join(RECH_DIR, "output");
286
307
  mkdirSync(workDir, { recursive: true });
287
308
 
309
+ // Foreground/orphan self-exit: a serve whose parent has been re-parented to init
310
+ // (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
311
+ // and exit once orphaned + idle so an agent that died without shutting us down
312
+ // doesn't leave a daemon behind.
313
+ let orphaned = false;
314
+ let idleSince = Date.now();
315
+ const markActivity = () => { idleSince = Date.now(); };
316
+ setInterval(() => {
317
+ if (process.ppid === 1) orphaned = true;
318
+ if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
319
+ log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
320
+ process.exit(0);
321
+ }
322
+ }, ORPHAN_POLL_INTERVAL_MS);
323
+
288
324
  // Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
289
325
  adoptOrphanedIsoSessions();
290
326
  setInterval(() => {
@@ -365,6 +401,7 @@ export async function serve() {
365
401
  if (reqUrl.pathname !== "/run") return new Response("rech server\n");
366
402
  const denied = authCheck(req, key);
367
403
  if (denied) return denied;
404
+ markActivity(); // a real command: this serve is not idle
368
405
 
369
406
  const body = await req.json();
370
407
  let args: string[];
package/serve.ts CHANGED
@@ -278,6 +278,27 @@ async function freeStalePort(port: number): Promise<void> {
278
278
  await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
279
279
  }
280
280
 
281
+ // --- Foreground/orphan self-exit ---------------------------------------------------
282
+ // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
283
+ // process-manager safety net: when the agent that spawned it exits, the OS re-parents
284
+ // the orphan to init (ppid 1) and it lives forever — the resource leak behind
285
+ // PERFORMANCE-EVENT.md. The managed daemon (oxmgr `--restart always`) keeps the
286
+ // process as its own child, so its ppid is stable and non-1 and it is never flagged.
287
+ // Once a serve is orphaned AND has had no real /run for the idle timeout, it exits so
288
+ // the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
289
+ // and would otherwise keep an orphan alive forever).
290
+ const ORPHAN_POLL_INTERVAL_MS = 15_000;
291
+ const ORPHAN_IDLE_EXIT_MS = Number(process.env.RECH_SERVE_IDLE_TIMEOUT_MS) || 5 * 60_000;
292
+
293
+ // Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
294
+ export function shouldExitOrphanedServe(opts: {
295
+ orphaned: boolean;
296
+ idleMs: number;
297
+ idleTimeoutMs: number;
298
+ }): boolean {
299
+ return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
300
+ }
301
+
281
302
  export async function serve() {
282
303
  const url = await getOrCreateUrl();
283
304
  const { key, port } = parseUrl(url);
@@ -285,6 +306,21 @@ export async function serve() {
285
306
  const workDir = join(RECH_DIR, "output");
286
307
  mkdirSync(workDir, { recursive: true });
287
308
 
309
+ // Foreground/orphan self-exit: a serve whose parent has been re-parented to init
310
+ // (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
311
+ // and exit once orphaned + idle so an agent that died without shutting us down
312
+ // doesn't leave a daemon behind.
313
+ let orphaned = false;
314
+ let idleSince = Date.now();
315
+ const markActivity = () => { idleSince = Date.now(); };
316
+ setInterval(() => {
317
+ if (process.ppid === 1) orphaned = true;
318
+ if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
319
+ log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
320
+ process.exit(0);
321
+ }
322
+ }, ORPHAN_POLL_INTERVAL_MS);
323
+
288
324
  // Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
289
325
  adoptOrphanedIsoSessions();
290
326
  setInterval(() => {
@@ -365,6 +401,7 @@ export async function serve() {
365
401
  if (reqUrl.pathname !== "/run") return new Response("rech server\n");
366
402
  const denied = authCheck(req, key);
367
403
  if (denied) return denied;
404
+ markActivity(); // a real command: this serve is not idle
368
405
 
369
406
  const body = await req.json();
370
407
  let args: string[];