rechrome 1.23.4 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,11 @@ Now `rechrome` (or `rech`) is available globally.
46
46
 
47
47
  `rech setup` configures the daemon, Chrome extension, and connection URL in one pass:
48
48
 
49
+ If no supported daemon manager is available, setup asks before installing `oxmgr` globally
50
+ (default: No). It uses `bun i -g oxmgr` when launched with bunx and `npm i -g oxmgr`
51
+ when launched with npx. Pass `--yes` to approve this installation without prompting,
52
+ for example `bunx rechrome setup --profile Default --yes`.
53
+
49
54
  ```bash
50
55
  rech setup # interactive: pick a profile, follow the prompts
51
56
  rech setup --profile you@email.com # non-interactive profile selection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.23.4",
3
+ "version": "1.24.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -6,7 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
6
6
  import { hostname, homedir } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
  import { spawn as cpSpawn } from "child_process";
9
- import { pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
9
+ import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
10
10
 
11
11
  export const ENV_KEY = "RECHROME_URL";
12
12
  export const DEFAULT_PORT = 13775;
@@ -174,17 +174,19 @@ export function parseUrl(raw: string) {
174
174
  };
175
175
  }
176
176
 
177
- export async function getOrCreateUrl(): Promise<string> {
177
+ export async function getOrCreateUrl(persist = true): Promise<string> {
178
178
  // Treat a URL without a bearer key as missing — it cannot authenticate
179
179
  try { if (process.env[ENV_KEY] && new URL(process.env[ENV_KEY]!).username) return process.env[ENV_KEY]!; } catch {}
180
180
  const key = randomBytes(12).toString("base64url"); // 16 chars
181
181
  const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
182
- const newLine = `${ENV_KEY}=${url}`;
183
- // Write to ~/.env.local so it's not shadowed by project .env.local
184
- const envRaw = await file(globalEnvFile).text().catch(() => "");
185
- const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
186
- const content = [...lines, newLine, ""].join("\n");
187
- Bun.write(globalEnvFile, content);
182
+ if (persist) {
183
+ const newLine = `${ENV_KEY}=${url}`;
184
+ // Write to ~/.env.local so it's not shadowed by project .env.local
185
+ const envRaw = await file(globalEnvFile).text().catch(() => "");
186
+ const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
187
+ const content = [...lines, newLine, ""].join("\n");
188
+ await Bun.write(globalEnvFile, content);
189
+ }
188
190
  process.env[ENV_KEY] = url;
189
191
  return url;
190
192
  }
@@ -400,6 +402,59 @@ export function validateChromeProfileSelector(selector: string): void {
400
402
  );
401
403
  }
402
404
 
405
+ export async function resolveGlobalProfile(
406
+ registry: Record<string, TokenEntry>,
407
+ chromeProfiles: Record<string, ChromeProfileInfo> | null,
408
+ selector: string,
409
+ ): Promise<{ email: string; entry: TokenEntry }> {
410
+ const value = selector.trim();
411
+ if (!value) throw new Error("--profile requires a non-empty value");
412
+
413
+ const registryKey = Object.keys(registry).find(k => k.toLowerCase() === value.toLowerCase());
414
+ if (registryKey) return { email: registryKey, entry: registry[registryKey] };
415
+
416
+ if (!chromeProfiles) {
417
+ throw new Error(
418
+ `--profile "${value}" does not match any registered email, and Chrome profiles are not accessible. ` +
419
+ `Run \`rech setup --profile "${value}"\` to register this profile.`,
420
+ );
421
+ }
422
+
423
+ const profiles = Object.entries(chromeProfiles);
424
+ let match: [string, ChromeProfileInfo] | null;
425
+ try {
426
+ match = resolveChromeProfileSelector(profiles, value);
427
+ } catch (err) {
428
+ throw err;
429
+ }
430
+
431
+ if (!match) {
432
+ throw new Error(
433
+ `--profile "${value}" does not match any Chrome profile. ` +
434
+ `See available profiles with \`rech profiles\`.`,
435
+ );
436
+ }
437
+
438
+ const [dir, info] = match;
439
+ const email = info.user_name;
440
+ if (!email) {
441
+ throw new Error(
442
+ `Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
443
+ `Run \`rech setup --profile "${value}"\` to register it.`,
444
+ );
445
+ }
446
+
447
+ const entry = registry[email];
448
+ if (!entry) {
449
+ throw new Error(
450
+ `Profile "${email}" (${dir}) is not registered. ` +
451
+ `Run \`rech setup --profile "${value}"\` to register it.`,
452
+ );
453
+ }
454
+
455
+ return { email, entry };
456
+ }
457
+
403
458
  async function findChromeUserDataDir(): Promise<string | null> {
404
459
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
405
460
  if (!(await file(statePath).exists())) continue;
@@ -545,7 +600,10 @@ async function callServe(
545
600
  // and run() has already done so for its log line. Recomputing here would double those git
546
601
  // spawns (and, on Windows, the console-window flashes) on every `rech open`.
547
602
  const identity = precomputedIdentity ?? await getClientIdentity();
548
- const effectiveProfile = resolveEffectiveProfile(profileDirectory);
603
+ // A global `--profile` override must win for the session key too: the daemon hashes
604
+ // identity.profile into the session id, so without this, `rech --profile other open` would
605
+ // reuse the default profile's session (and its browser) instead of opening its own.
606
+ const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
549
607
  if (effectiveProfile) identity.profile = effectiveProfile;
550
608
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
551
609
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
@@ -588,12 +646,43 @@ export function normalizeCommandArgs(args: string[]): string[] {
588
646
  return normalized;
589
647
  }
590
648
 
591
- async function run(url: string, args: string[]) {
649
+ // Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
650
+ // Only flags before the first positional (the playwright subcommand) are rech globals — a
651
+ // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
652
+ // `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
653
+ // missing value; accepts multiple occurrences (last one wins).
654
+ export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
655
+ const rest = [...args];
656
+ let selector: string | undefined;
657
+ for (let i = 0; i < rest.length && rest[i].startsWith("-"); i++) {
658
+ const a = rest[i];
659
+ if (a === "--profile") {
660
+ const value = rest[i + 1];
661
+ if (!value || value.startsWith("--"))
662
+ throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
663
+ selector = value;
664
+ rest.splice(i, 2);
665
+ i--;
666
+ continue;
667
+ }
668
+ if (a.startsWith("--profile=")) {
669
+ const value = a.slice("--profile=".length);
670
+ if (!value) throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
671
+ selector = value;
672
+ rest.splice(i, 1);
673
+ i--;
674
+ continue;
675
+ }
676
+ }
677
+ return { args: rest, selector };
678
+ }
679
+
680
+ async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
592
681
  // Match the underlying CLI's command names while accepting the short forms humans
593
682
  // naturally try. Keep this client-side so old and new serve daemons behave alike.
594
683
  args = normalizeCommandArgs(args);
595
684
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
596
- const effectiveProfile = resolveEffectiveProfile(profileDirectory);
685
+ const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
597
686
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
598
687
  const identity = await getClientIdentity();
599
688
  const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
@@ -602,18 +691,19 @@ async function run(url: string, args: string[]) {
602
691
  );
603
692
 
604
693
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
605
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
694
+ const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
695
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
606
696
 
607
697
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
608
698
  if (existingSession && isOpenWithUrl) {
609
- return run(url, ["goto", ...args.slice(1)]);
699
+ return run(url, ["goto", ...args.slice(1)], overrideEnv);
610
700
  }
611
701
 
612
702
  if (existingSession)
613
703
  console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
614
704
  if (stderr) {
615
705
  if (stderr.includes('Extension connection timeout')) {
616
- const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
706
+ const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
617
707
  const last = hasToken
618
708
  ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
619
709
  : ` -> extension[not installed] (run: rech setup)`;
@@ -810,6 +900,7 @@ export function resolvePlaywrightCli(): string {
810
900
  }
811
901
 
812
902
  export async function daemonInstall(serveUrl: string): Promise<void> {
903
+ const mgr = daemonManager();
813
904
  // Persist the URL to ~/.env.local before starting the daemon. The daemon's
814
905
  // loadEnv() walks CWD→root reading .env.local files and unconditionally
815
906
  // overwrites process.env.RECHROME_URL from whichever file it finds first.
@@ -846,8 +937,6 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
846
937
  if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
847
938
  if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
848
939
 
849
- const mgr = daemonManager();
850
-
851
940
  // Drop any prior registration (current + legacy names) before re-adding.
852
941
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
853
942
 
@@ -923,12 +1012,51 @@ function findTrayBinary(): string | undefined {
923
1012
  return Bun.which(`rechrome-tray${ext}`) ?? undefined;
924
1013
  }
925
1014
 
1015
+ // Verify a PID is actually a rechrome-tray process, not an unrelated process that
1016
+ // happened to reuse the pid after the real tray died. POSIX only (`ps` isn't on stock
1017
+ // Windows); there we fall back to trusting the liveness probe alone (best-effort).
1018
+ function isPidRechromeTray(pid: number): boolean {
1019
+ if (IS_WINDOWS) return true;
1020
+ try {
1021
+ const p = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1022
+ return (p.stdout?.toString() ?? "").trim().includes("rechrome-tray");
1023
+ } catch {
1024
+ return false;
1025
+ }
1026
+ }
1027
+
1028
+ // PIDs of live rechrome-tray processes (POSIX). Catches untracked trays — ones launched
1029
+ // outside `rech tray show` that never touch the pidfile — so a singleton guard can't
1030
+ // spawn a second icon on top of them. Empty on Windows (no `ps`), where we rely on the
1031
+ // pidfile liveness check alone.
1032
+ function listTrayPids(): number[] {
1033
+ if (IS_WINDOWS) return [];
1034
+ try {
1035
+ const p = Bun.spawnSync(["ps", "-axo", "pid=,comm="], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1036
+ const out = p.stdout?.toString() ?? "";
1037
+ const pids: number[] = [];
1038
+ for (const line of out.split("\n")) {
1039
+ const m = line.trim().match(/^(\d+)\s+(.+)$/);
1040
+ // macOS reports the full launch path in `comm`, so match the basename via a
1041
+ // trailing "/rechrome-tray" or an exact bare name (Linux truncates to the name).
1042
+ if (m && Number(m[1]) !== process.pid && m[2].includes("rechrome-tray")) pids.push(Number(m[1]));
1043
+ }
1044
+ return pids;
1045
+ } catch {
1046
+ return [];
1047
+ }
1048
+ }
1049
+
926
1050
  function isTrayRunning(): boolean {
1051
+ // Any live tray (tracked by the pidfile or not) means "already running" — the pidfile
1052
+ // is a single slot that only ever records the most-recent spawn, so it cannot see
1053
+ // earlier/untracked instances on its own.
1054
+ if (listTrayPids().length > 0) return true;
927
1055
  try {
928
1056
  const pid = parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10);
929
1057
  if (!Number.isFinite(pid)) return false;
930
1058
  process.kill(pid, 0); // signal 0 = liveness probe, doesn't actually signal
931
- return true;
1059
+ return isPidRechromeTray(pid);
932
1060
  } catch {
933
1061
  return false;
934
1062
  }
@@ -958,7 +1086,12 @@ async function startTray({ quiet = false }: { quiet?: boolean } = {}): Promise<v
958
1086
  }
959
1087
 
960
1088
  function stopTray(): void {
961
- if (!isTrayRunning()) { console.log("tray: not running."); return; }
1089
+ // Kill every live tray (tracked or untracked), then drop the pidfile. A singleton
1090
+ // tray should never have more than one instance, but a crashed/overwritten pidfile
1091
+ // can leave orphans the single-slot pidfile no longer points at — reap them too.
1092
+ const pids = listTrayPids();
1093
+ if (pids.length === 0 && !isTrayRunning()) { console.log("tray: not running."); return; }
1094
+ for (const pid of pids) { try { process.kill(pid); } catch {} }
962
1095
  try { process.kill(parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10)); } catch {}
963
1096
  try { unlinkSync(TRAY_PID_FILE); } catch {}
964
1097
  console.log("tray: stopped. Run `rech tray show` to restore.");
@@ -1187,7 +1320,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1187
1320
  console.log(`Or save it to a project .env.local to make it the default.`);
1188
1321
  }
1189
1322
 
1190
- async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
1323
+ async function setup(opts: { profile?: string; token?: string; yes?: boolean } = {}): Promise<void> {
1191
1324
  if (opts.profile !== undefined) {
1192
1325
  try {
1193
1326
  validateChromeProfileSelector(opts.profile);
@@ -1234,7 +1367,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1234
1367
  if (!["127.0.0.1", "localhost"].includes(u.hostname)) delete process.env[ENV_KEY];
1235
1368
  } catch {}
1236
1369
  }
1237
- const url = await getOrCreateUrl();
1370
+ // Defer persistence until daemon prerequisites have passed.
1371
+ const url = await getOrCreateUrl(false);
1238
1372
  const { host, port, protocol } = parseUrl(url);
1239
1373
 
1240
1374
  const { key: serveKey } = parseUrl(url);
@@ -1272,6 +1406,40 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1272
1406
  console.log(` Daemon already running at ${protocol}://${host}:${port} (bind: ${currentBind}) — skipping daemon setup`);
1273
1407
  }
1274
1408
  const bindChanged = desiredBind !== currentBind;
1409
+ if (!daemonHealthy || bindChanged) {
1410
+ try {
1411
+ try {
1412
+ daemonManager();
1413
+ } catch (error) {
1414
+ // Respect an explicit pm2 choice; installing oxmgr would not satisfy it.
1415
+ if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
1416
+ const command = oxmgrInstallCommand(process.env);
1417
+ const answer = opts.yes ? "yes" : (await ask(` oxmgr is missing. Install globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
1418
+ if (!/^(y|yes)$/i.test(answer)) {
1419
+ throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
1420
+ }
1421
+ console.log(` Installing oxmgr: ${command.join(" ")}`);
1422
+ const installer = Bun.which(command[0]) ?? (command[0] === "bun" ? process.execPath : null);
1423
+ if (!installer) throw new Error(`${command[0]} is not on PATH. Install it or run \`${command.join(" ")}\` in your terminal, then rerun setup.`);
1424
+ const proc = Bun.spawn([installer, ...command.slice(1)], {
1425
+ stdin: "inherit", stdout: "inherit", stderr: "inherit", windowsHide: true,
1426
+ });
1427
+ const code = await proc.exited;
1428
+ if (code !== 0) throw new Error(`\`${command.join(" ")}\` failed (exit code ${code}). Resolve the installation error, then rerun setup.`);
1429
+ if (!Bun.which("oxmgr")) {
1430
+ throw new Error("oxmgr was installed but is not on PATH. Add the package manager's global bin directory to PATH, then rerun setup.");
1431
+ }
1432
+ _daemonMgr = undefined;
1433
+ _oxmgrVersion = undefined;
1434
+ daemonManager();
1435
+ }
1436
+ } catch (error) {
1437
+ console.error(error instanceof Error ? error.message : String(error));
1438
+ rl?.close();
1439
+ envWatcher?.close();
1440
+ process.exit(1);
1441
+ }
1442
+ }
1275
1443
  const persistedChanged = desiredBind !== persistedBind;
1276
1444
  if (persistedChanged) {
1277
1445
  const lines = globalEnvRaw.trimEnd().split("\n").filter(l => !/^\s*RECH_HOST\s*=/.test(l));
@@ -1623,8 +1791,18 @@ function printHelp(): void {
1623
1791
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1624
1792
 
1625
1793
  Usage:
1626
- rech setup [--profile <email|name|folder>] [--token <tok>]
1794
+ rech [--profile <email|name|folder>] <playwright-args...>
1795
+ Run Playwright CLI command with the given registered
1796
+ Chrome profile. --profile selects the profile by exact
1797
+ registered email (e.g. you@gmail.com), exact Chrome
1798
+ profile name, or exact profile folder name. The profile
1799
+ must already be registered (see \`rech setup\`). Place
1800
+ --profile before the playwright subcommand. Requires
1801
+ ${ENV_KEY}.
1802
+ rech setup [--profile <email|name|folder>] [--token <tok>] [--yes]
1627
1803
  First-time setup: daemon + Chrome extension + config
1804
+ Offers to install missing oxmgr globally (y/N).
1805
+ --yes approves installation without prompting.
1628
1806
  --profile selects the Chrome profile non-interactively.
1629
1807
  Menu numbers are not accepted. Resolution order is exact
1630
1808
  email (e.g. you@gmail.com), exact Chrome profile name,
@@ -1659,13 +1837,14 @@ Environment:
1659
1837
  Examples:
1660
1838
  rech setup
1661
1839
  rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1840
+ rech --profile you@gmail.com open https://example.com
1662
1841
  rech eval "() => document.title"
1663
1842
  rech open https://example.com
1664
1843
  rech screenshot`);
1665
1844
  }
1666
1845
 
1667
1846
  if (import.meta.main) {
1668
- const args = process.argv.slice(2);
1847
+ let args = process.argv.slice(2);
1669
1848
  const cmd = args[0]?.toLowerCase();
1670
1849
 
1671
1850
  if (cmd === "serve") {
@@ -1687,7 +1866,7 @@ if (import.meta.main) {
1687
1866
  ? args[tokenIdx + 1]
1688
1867
  : args.find(a => a.startsWith("--token="))?.slice("--token=".length))
1689
1868
  ?? process.env.RECH_TOKEN;
1690
- await setup({ profile, token }); // setup closes envWatcher itself before printing Done
1869
+ await setup({ profile, token, yes: args.includes("--yes") }); // setup closes envWatcher itself before printing Done
1691
1870
  // Auto-start the tray (best-effort, silent on headless / missing binary).
1692
1871
  await startTray({ quiet: true }).catch(() => {});
1693
1872
  } else if (cmd === "tray") {
@@ -1723,6 +1902,41 @@ if (import.meta.main) {
1723
1902
  printHelp();
1724
1903
  process.exit(1);
1725
1904
  }
1905
+ // --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
1906
+ // the leading-flags-only rule that protects the forwarded CLI's own --profile).
1907
+ let profileSelector: string | undefined;
1908
+ let overrideEnv: Record<string, string> | undefined;
1909
+ try {
1910
+ const extracted = extractGlobalProfileArg(args);
1911
+ profileSelector = extracted.selector;
1912
+ args = extracted.args;
1913
+ } catch (err) {
1914
+ console.error(err instanceof Error ? err.message : String(err));
1915
+ envWatcher?.close();
1916
+ process.exit(1);
1917
+ }
1918
+ if (profileSelector !== undefined) {
1919
+ try {
1920
+ const registry = await readTokenRegistry();
1921
+ const cache = await readChromeProfileCache();
1922
+ const resolved = await resolveGlobalProfile(registry, cache, profileSelector);
1923
+ // Use the registry key (email, or the managed profile name) as the profile identity:
1924
+ // the daemon already resolves email/name → profile dir for the default URL-param path,
1925
+ // so this keeps `--profile <email>` on the SAME session as the default path and only
1926
+ // opens a separate session when the profile really differs.
1927
+ overrideEnv = {
1928
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
1929
+ PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
1930
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
1931
+ };
1932
+ if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
1933
+ if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;
1934
+ } catch (err) {
1935
+ console.error(err instanceof Error ? err.message : String(err));
1936
+ envWatcher?.close();
1937
+ process.exit(1);
1938
+ }
1939
+ }
1726
1940
  // --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
1727
1941
  // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
1728
1942
  // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.js), so an
@@ -1732,7 +1946,7 @@ if (import.meta.main) {
1732
1946
  args.splice(isolateIdx, 1);
1733
1947
  args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
1734
1948
  }
1735
- await run(url, args);
1949
+ await run(url, args, overrideEnv);
1736
1950
  envWatcher?.close();
1737
1951
  }
1738
1952
  }
package/rech.ts CHANGED
@@ -6,7 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
6
6
  import { hostname, homedir } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
  import { spawn as cpSpawn } from "child_process";
9
- import { pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
9
+ import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
10
10
 
11
11
  export const ENV_KEY = "RECHROME_URL";
12
12
  export const DEFAULT_PORT = 13775;
@@ -174,17 +174,19 @@ export function parseUrl(raw: string) {
174
174
  };
175
175
  }
176
176
 
177
- export async function getOrCreateUrl(): Promise<string> {
177
+ export async function getOrCreateUrl(persist = true): Promise<string> {
178
178
  // Treat a URL without a bearer key as missing — it cannot authenticate
179
179
  try { if (process.env[ENV_KEY] && new URL(process.env[ENV_KEY]!).username) return process.env[ENV_KEY]!; } catch {}
180
180
  const key = randomBytes(12).toString("base64url"); // 16 chars
181
181
  const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
182
- const newLine = `${ENV_KEY}=${url}`;
183
- // Write to ~/.env.local so it's not shadowed by project .env.local
184
- const envRaw = await file(globalEnvFile).text().catch(() => "");
185
- const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
186
- const content = [...lines, newLine, ""].join("\n");
187
- Bun.write(globalEnvFile, content);
182
+ if (persist) {
183
+ const newLine = `${ENV_KEY}=${url}`;
184
+ // Write to ~/.env.local so it's not shadowed by project .env.local
185
+ const envRaw = await file(globalEnvFile).text().catch(() => "");
186
+ const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
187
+ const content = [...lines, newLine, ""].join("\n");
188
+ await Bun.write(globalEnvFile, content);
189
+ }
188
190
  process.env[ENV_KEY] = url;
189
191
  return url;
190
192
  }
@@ -400,6 +402,59 @@ export function validateChromeProfileSelector(selector: string): void {
400
402
  );
401
403
  }
402
404
 
405
+ export async function resolveGlobalProfile(
406
+ registry: Record<string, TokenEntry>,
407
+ chromeProfiles: Record<string, ChromeProfileInfo> | null,
408
+ selector: string,
409
+ ): Promise<{ email: string; entry: TokenEntry }> {
410
+ const value = selector.trim();
411
+ if (!value) throw new Error("--profile requires a non-empty value");
412
+
413
+ const registryKey = Object.keys(registry).find(k => k.toLowerCase() === value.toLowerCase());
414
+ if (registryKey) return { email: registryKey, entry: registry[registryKey] };
415
+
416
+ if (!chromeProfiles) {
417
+ throw new Error(
418
+ `--profile "${value}" does not match any registered email, and Chrome profiles are not accessible. ` +
419
+ `Run \`rech setup --profile "${value}"\` to register this profile.`,
420
+ );
421
+ }
422
+
423
+ const profiles = Object.entries(chromeProfiles);
424
+ let match: [string, ChromeProfileInfo] | null;
425
+ try {
426
+ match = resolveChromeProfileSelector(profiles, value);
427
+ } catch (err) {
428
+ throw err;
429
+ }
430
+
431
+ if (!match) {
432
+ throw new Error(
433
+ `--profile "${value}" does not match any Chrome profile. ` +
434
+ `See available profiles with \`rech profiles\`.`,
435
+ );
436
+ }
437
+
438
+ const [dir, info] = match;
439
+ const email = info.user_name;
440
+ if (!email) {
441
+ throw new Error(
442
+ `Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
443
+ `Run \`rech setup --profile "${value}"\` to register it.`,
444
+ );
445
+ }
446
+
447
+ const entry = registry[email];
448
+ if (!entry) {
449
+ throw new Error(
450
+ `Profile "${email}" (${dir}) is not registered. ` +
451
+ `Run \`rech setup --profile "${value}"\` to register it.`,
452
+ );
453
+ }
454
+
455
+ return { email, entry };
456
+ }
457
+
403
458
  async function findChromeUserDataDir(): Promise<string | null> {
404
459
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
405
460
  if (!(await file(statePath).exists())) continue;
@@ -545,7 +600,10 @@ async function callServe(
545
600
  // and run() has already done so for its log line. Recomputing here would double those git
546
601
  // spawns (and, on Windows, the console-window flashes) on every `rech open`.
547
602
  const identity = precomputedIdentity ?? await getClientIdentity();
548
- const effectiveProfile = resolveEffectiveProfile(profileDirectory);
603
+ // A global `--profile` override must win for the session key too: the daemon hashes
604
+ // identity.profile into the session id, so without this, `rech --profile other open` would
605
+ // reuse the default profile's session (and its browser) instead of opening its own.
606
+ const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
549
607
  if (effectiveProfile) identity.profile = effectiveProfile;
550
608
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
551
609
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
@@ -588,12 +646,43 @@ export function normalizeCommandArgs(args: string[]): string[] {
588
646
  return normalized;
589
647
  }
590
648
 
591
- async function run(url: string, args: string[]) {
649
+ // Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
650
+ // Only flags before the first positional (the playwright subcommand) are rech globals — a
651
+ // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
652
+ // `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
653
+ // missing value; accepts multiple occurrences (last one wins).
654
+ export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
655
+ const rest = [...args];
656
+ let selector: string | undefined;
657
+ for (let i = 0; i < rest.length && rest[i].startsWith("-"); i++) {
658
+ const a = rest[i];
659
+ if (a === "--profile") {
660
+ const value = rest[i + 1];
661
+ if (!value || value.startsWith("--"))
662
+ throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
663
+ selector = value;
664
+ rest.splice(i, 2);
665
+ i--;
666
+ continue;
667
+ }
668
+ if (a.startsWith("--profile=")) {
669
+ const value = a.slice("--profile=".length);
670
+ if (!value) throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
671
+ selector = value;
672
+ rest.splice(i, 1);
673
+ i--;
674
+ continue;
675
+ }
676
+ }
677
+ return { args: rest, selector };
678
+ }
679
+
680
+ async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
592
681
  // Match the underlying CLI's command names while accepting the short forms humans
593
682
  // naturally try. Keep this client-side so old and new serve daemons behave alike.
594
683
  args = normalizeCommandArgs(args);
595
684
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
596
- const effectiveProfile = resolveEffectiveProfile(profileDirectory);
685
+ const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
597
686
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
598
687
  const identity = await getClientIdentity();
599
688
  const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
@@ -602,18 +691,19 @@ async function run(url: string, args: string[]) {
602
691
  );
603
692
 
604
693
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
605
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
694
+ const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
695
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
606
696
 
607
697
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
608
698
  if (existingSession && isOpenWithUrl) {
609
- return run(url, ["goto", ...args.slice(1)]);
699
+ return run(url, ["goto", ...args.slice(1)], overrideEnv);
610
700
  }
611
701
 
612
702
  if (existingSession)
613
703
  console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
614
704
  if (stderr) {
615
705
  if (stderr.includes('Extension connection timeout')) {
616
- const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
706
+ const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
617
707
  const last = hasToken
618
708
  ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
619
709
  : ` -> extension[not installed] (run: rech setup)`;
@@ -810,6 +900,7 @@ export function resolvePlaywrightCli(): string {
810
900
  }
811
901
 
812
902
  export async function daemonInstall(serveUrl: string): Promise<void> {
903
+ const mgr = daemonManager();
813
904
  // Persist the URL to ~/.env.local before starting the daemon. The daemon's
814
905
  // loadEnv() walks CWD→root reading .env.local files and unconditionally
815
906
  // overwrites process.env.RECHROME_URL from whichever file it finds first.
@@ -846,8 +937,6 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
846
937
  if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
847
938
  if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
848
939
 
849
- const mgr = daemonManager();
850
-
851
940
  // Drop any prior registration (current + legacy names) before re-adding.
852
941
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
853
942
 
@@ -923,12 +1012,51 @@ function findTrayBinary(): string | undefined {
923
1012
  return Bun.which(`rechrome-tray${ext}`) ?? undefined;
924
1013
  }
925
1014
 
1015
+ // Verify a PID is actually a rechrome-tray process, not an unrelated process that
1016
+ // happened to reuse the pid after the real tray died. POSIX only (`ps` isn't on stock
1017
+ // Windows); there we fall back to trusting the liveness probe alone (best-effort).
1018
+ function isPidRechromeTray(pid: number): boolean {
1019
+ if (IS_WINDOWS) return true;
1020
+ try {
1021
+ const p = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1022
+ return (p.stdout?.toString() ?? "").trim().includes("rechrome-tray");
1023
+ } catch {
1024
+ return false;
1025
+ }
1026
+ }
1027
+
1028
+ // PIDs of live rechrome-tray processes (POSIX). Catches untracked trays — ones launched
1029
+ // outside `rech tray show` that never touch the pidfile — so a singleton guard can't
1030
+ // spawn a second icon on top of them. Empty on Windows (no `ps`), where we rely on the
1031
+ // pidfile liveness check alone.
1032
+ function listTrayPids(): number[] {
1033
+ if (IS_WINDOWS) return [];
1034
+ try {
1035
+ const p = Bun.spawnSync(["ps", "-axo", "pid=,comm="], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1036
+ const out = p.stdout?.toString() ?? "";
1037
+ const pids: number[] = [];
1038
+ for (const line of out.split("\n")) {
1039
+ const m = line.trim().match(/^(\d+)\s+(.+)$/);
1040
+ // macOS reports the full launch path in `comm`, so match the basename via a
1041
+ // trailing "/rechrome-tray" or an exact bare name (Linux truncates to the name).
1042
+ if (m && Number(m[1]) !== process.pid && m[2].includes("rechrome-tray")) pids.push(Number(m[1]));
1043
+ }
1044
+ return pids;
1045
+ } catch {
1046
+ return [];
1047
+ }
1048
+ }
1049
+
926
1050
  function isTrayRunning(): boolean {
1051
+ // Any live tray (tracked by the pidfile or not) means "already running" — the pidfile
1052
+ // is a single slot that only ever records the most-recent spawn, so it cannot see
1053
+ // earlier/untracked instances on its own.
1054
+ if (listTrayPids().length > 0) return true;
927
1055
  try {
928
1056
  const pid = parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10);
929
1057
  if (!Number.isFinite(pid)) return false;
930
1058
  process.kill(pid, 0); // signal 0 = liveness probe, doesn't actually signal
931
- return true;
1059
+ return isPidRechromeTray(pid);
932
1060
  } catch {
933
1061
  return false;
934
1062
  }
@@ -958,7 +1086,12 @@ async function startTray({ quiet = false }: { quiet?: boolean } = {}): Promise<v
958
1086
  }
959
1087
 
960
1088
  function stopTray(): void {
961
- if (!isTrayRunning()) { console.log("tray: not running."); return; }
1089
+ // Kill every live tray (tracked or untracked), then drop the pidfile. A singleton
1090
+ // tray should never have more than one instance, but a crashed/overwritten pidfile
1091
+ // can leave orphans the single-slot pidfile no longer points at — reap them too.
1092
+ const pids = listTrayPids();
1093
+ if (pids.length === 0 && !isTrayRunning()) { console.log("tray: not running."); return; }
1094
+ for (const pid of pids) { try { process.kill(pid); } catch {} }
962
1095
  try { process.kill(parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10)); } catch {}
963
1096
  try { unlinkSync(TRAY_PID_FILE); } catch {}
964
1097
  console.log("tray: stopped. Run `rech tray show` to restore.");
@@ -1187,7 +1320,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1187
1320
  console.log(`Or save it to a project .env.local to make it the default.`);
1188
1321
  }
1189
1322
 
1190
- async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
1323
+ async function setup(opts: { profile?: string; token?: string; yes?: boolean } = {}): Promise<void> {
1191
1324
  if (opts.profile !== undefined) {
1192
1325
  try {
1193
1326
  validateChromeProfileSelector(opts.profile);
@@ -1234,7 +1367,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1234
1367
  if (!["127.0.0.1", "localhost"].includes(u.hostname)) delete process.env[ENV_KEY];
1235
1368
  } catch {}
1236
1369
  }
1237
- const url = await getOrCreateUrl();
1370
+ // Defer persistence until daemon prerequisites have passed.
1371
+ const url = await getOrCreateUrl(false);
1238
1372
  const { host, port, protocol } = parseUrl(url);
1239
1373
 
1240
1374
  const { key: serveKey } = parseUrl(url);
@@ -1272,6 +1406,40 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1272
1406
  console.log(` Daemon already running at ${protocol}://${host}:${port} (bind: ${currentBind}) — skipping daemon setup`);
1273
1407
  }
1274
1408
  const bindChanged = desiredBind !== currentBind;
1409
+ if (!daemonHealthy || bindChanged) {
1410
+ try {
1411
+ try {
1412
+ daemonManager();
1413
+ } catch (error) {
1414
+ // Respect an explicit pm2 choice; installing oxmgr would not satisfy it.
1415
+ if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
1416
+ const command = oxmgrInstallCommand(process.env);
1417
+ const answer = opts.yes ? "yes" : (await ask(` oxmgr is missing. Install globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
1418
+ if (!/^(y|yes)$/i.test(answer)) {
1419
+ throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
1420
+ }
1421
+ console.log(` Installing oxmgr: ${command.join(" ")}`);
1422
+ const installer = Bun.which(command[0]) ?? (command[0] === "bun" ? process.execPath : null);
1423
+ if (!installer) throw new Error(`${command[0]} is not on PATH. Install it or run \`${command.join(" ")}\` in your terminal, then rerun setup.`);
1424
+ const proc = Bun.spawn([installer, ...command.slice(1)], {
1425
+ stdin: "inherit", stdout: "inherit", stderr: "inherit", windowsHide: true,
1426
+ });
1427
+ const code = await proc.exited;
1428
+ if (code !== 0) throw new Error(`\`${command.join(" ")}\` failed (exit code ${code}). Resolve the installation error, then rerun setup.`);
1429
+ if (!Bun.which("oxmgr")) {
1430
+ throw new Error("oxmgr was installed but is not on PATH. Add the package manager's global bin directory to PATH, then rerun setup.");
1431
+ }
1432
+ _daemonMgr = undefined;
1433
+ _oxmgrVersion = undefined;
1434
+ daemonManager();
1435
+ }
1436
+ } catch (error) {
1437
+ console.error(error instanceof Error ? error.message : String(error));
1438
+ rl?.close();
1439
+ envWatcher?.close();
1440
+ process.exit(1);
1441
+ }
1442
+ }
1275
1443
  const persistedChanged = desiredBind !== persistedBind;
1276
1444
  if (persistedChanged) {
1277
1445
  const lines = globalEnvRaw.trimEnd().split("\n").filter(l => !/^\s*RECH_HOST\s*=/.test(l));
@@ -1623,8 +1791,18 @@ function printHelp(): void {
1623
1791
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1624
1792
 
1625
1793
  Usage:
1626
- rech setup [--profile <email|name|folder>] [--token <tok>]
1794
+ rech [--profile <email|name|folder>] <playwright-args...>
1795
+ Run Playwright CLI command with the given registered
1796
+ Chrome profile. --profile selects the profile by exact
1797
+ registered email (e.g. you@gmail.com), exact Chrome
1798
+ profile name, or exact profile folder name. The profile
1799
+ must already be registered (see \`rech setup\`). Place
1800
+ --profile before the playwright subcommand. Requires
1801
+ ${ENV_KEY}.
1802
+ rech setup [--profile <email|name|folder>] [--token <tok>] [--yes]
1627
1803
  First-time setup: daemon + Chrome extension + config
1804
+ Offers to install missing oxmgr globally (y/N).
1805
+ --yes approves installation without prompting.
1628
1806
  --profile selects the Chrome profile non-interactively.
1629
1807
  Menu numbers are not accepted. Resolution order is exact
1630
1808
  email (e.g. you@gmail.com), exact Chrome profile name,
@@ -1659,13 +1837,14 @@ Environment:
1659
1837
  Examples:
1660
1838
  rech setup
1661
1839
  rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1840
+ rech --profile you@gmail.com open https://example.com
1662
1841
  rech eval "() => document.title"
1663
1842
  rech open https://example.com
1664
1843
  rech screenshot`);
1665
1844
  }
1666
1845
 
1667
1846
  if (import.meta.main) {
1668
- const args = process.argv.slice(2);
1847
+ let args = process.argv.slice(2);
1669
1848
  const cmd = args[0]?.toLowerCase();
1670
1849
 
1671
1850
  if (cmd === "serve") {
@@ -1687,7 +1866,7 @@ if (import.meta.main) {
1687
1866
  ? args[tokenIdx + 1]
1688
1867
  : args.find(a => a.startsWith("--token="))?.slice("--token=".length))
1689
1868
  ?? process.env.RECH_TOKEN;
1690
- await setup({ profile, token }); // setup closes envWatcher itself before printing Done
1869
+ await setup({ profile, token, yes: args.includes("--yes") }); // setup closes envWatcher itself before printing Done
1691
1870
  // Auto-start the tray (best-effort, silent on headless / missing binary).
1692
1871
  await startTray({ quiet: true }).catch(() => {});
1693
1872
  } else if (cmd === "tray") {
@@ -1723,6 +1902,41 @@ if (import.meta.main) {
1723
1902
  printHelp();
1724
1903
  process.exit(1);
1725
1904
  }
1905
+ // --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
1906
+ // the leading-flags-only rule that protects the forwarded CLI's own --profile).
1907
+ let profileSelector: string | undefined;
1908
+ let overrideEnv: Record<string, string> | undefined;
1909
+ try {
1910
+ const extracted = extractGlobalProfileArg(args);
1911
+ profileSelector = extracted.selector;
1912
+ args = extracted.args;
1913
+ } catch (err) {
1914
+ console.error(err instanceof Error ? err.message : String(err));
1915
+ envWatcher?.close();
1916
+ process.exit(1);
1917
+ }
1918
+ if (profileSelector !== undefined) {
1919
+ try {
1920
+ const registry = await readTokenRegistry();
1921
+ const cache = await readChromeProfileCache();
1922
+ const resolved = await resolveGlobalProfile(registry, cache, profileSelector);
1923
+ // Use the registry key (email, or the managed profile name) as the profile identity:
1924
+ // the daemon already resolves email/name → profile dir for the default URL-param path,
1925
+ // so this keeps `--profile <email>` on the SAME session as the default path and only
1926
+ // opens a separate session when the profile really differs.
1927
+ overrideEnv = {
1928
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
1929
+ PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
1930
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
1931
+ };
1932
+ if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
1933
+ if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;
1934
+ } catch (err) {
1935
+ console.error(err instanceof Error ? err.message : String(err));
1936
+ envWatcher?.close();
1937
+ process.exit(1);
1938
+ }
1939
+ }
1726
1940
  // --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
1727
1941
  // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
1728
1942
  // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.ts), so an
@@ -1732,7 +1946,7 @@ if (import.meta.main) {
1732
1946
  args.splice(isolateIdx, 1);
1733
1947
  args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
1734
1948
  }
1735
- await run(url, args);
1949
+ await run(url, args, overrideEnv);
1736
1950
  envWatcher?.close();
1737
1951
  }
1738
1952
  }
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[];