rechrome 1.21.0 → 1.23.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 +140 -39
  3. package/rech.ts +140 -39
  4. package/serve.js +130 -19
  5. package/serve.ts +130 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -6,6 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
6
6
  import { hostname, homedir } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
  import { spawn as cpSpawn } from "child_process";
9
+ import { pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
9
10
 
10
11
  export const ENV_KEY = "RECHROME_URL";
11
12
  export const DEFAULT_PORT = 13775;
@@ -104,7 +105,7 @@ function openInDefaultApp(target: string): void {
104
105
  const cmd = process.platform === "darwin" ? ["open", target]
105
106
  : process.platform === "win32" ? ["cmd", "/c", "start", "", target]
106
107
  : ["xdg-open", target];
107
- try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
108
+ try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true }); } catch {}
108
109
  }
109
110
 
110
111
  // Best-effort path to the Chrome executable for the current platform (used to open a
@@ -137,7 +138,7 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
137
138
  try {
138
139
  Bun.spawn(
139
140
  [chromeBin, `--profile-directory=${profileDir}`, target],
140
- { stdout: "ignore", stderr: "ignore", detached: true },
141
+ { stdout: "ignore", stderr: "ignore", detached: true, windowsHide: true },
141
142
  );
142
143
  return true;
143
144
  } catch {
@@ -200,7 +201,8 @@ function realpathSafe(p: string): string {
200
201
 
201
202
  async function gitOutput(args: string[], cwd: string): Promise<string | null> {
202
203
  try {
203
- const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
204
+ // windowsHide: don't flash a console window on Windows (git.exe is a console app)
205
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore", windowsHide: true });
204
206
  const out = (await new Response(proc.stdout).text()).trim();
205
207
  await proc.exited;
206
208
  return out || null;
@@ -478,12 +480,18 @@ async function listProfiles(): Promise<void> {
478
480
  }
479
481
  }
480
482
 
481
- const rows = Object.entries(cache).map(([dir, info]) => [
482
- dir,
483
- info.user_name || "",
484
- info.name || "",
485
- dir === currentDir ? " current" : "",
486
- ]);
483
+ // Header clarifies each column; email/nickname sit beside the profile dir so a bare
484
+ // "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
485
+ // from Local State — the gaia real name is deliberately never surfaced.
486
+ const rows = [
487
+ ["PROFILE", "EMAIL", "NICKNAME", ""],
488
+ ...Object.entries(cache).map(([dir, info]) => [
489
+ dir,
490
+ info.user_name || "",
491
+ info.name || "",
492
+ dir === currentDir ? "← current" : "",
493
+ ]),
494
+ ];
487
495
  const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
488
496
  for (const row of rows) {
489
497
  console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
@@ -494,9 +502,13 @@ async function callServe(
494
502
  url: string,
495
503
  args: string[],
496
504
  overrideEnv?: Record<string, string>,
505
+ precomputedIdentity?: { key: string; label: string; profile?: string },
497
506
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
498
507
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
499
- const identity = await getClientIdentity();
508
+ // Reuse the caller's identity when provided — computing it shells out to `git` several times,
509
+ // and run() has already done so for its log line. Recomputing here would double those git
510
+ // spawns (and, on Windows, the console-window flashes) on every `rech open`.
511
+ const identity = precomputedIdentity ?? await getClientIdentity();
500
512
  const effectiveProfile = resolveEffectiveProfile(profileDirectory);
501
513
  if (effectiveProfile) identity.profile = effectiveProfile;
502
514
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
@@ -545,7 +557,7 @@ async function run(url: string, args: string[]) {
545
557
  );
546
558
 
547
559
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
548
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
560
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
549
561
 
550
562
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
551
563
  if (existingSession && isOpenWithUrl) {
@@ -647,27 +659,80 @@ const PM_PROCESS_NAME = "rechrome";
647
659
  // Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
648
660
  // migrates an existing checkout cleanly.
649
661
  const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
650
- // oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
651
662
  const IS_WINDOWS = process.platform === "win32";
652
- const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
653
663
 
654
- // Spawn the active process manager. `env` is merged over process.env for the
655
- // child: pm2 captures the CLI's environment for the managed process (it has no
656
- // per-var flag like oxmgr's --env), so install passes daemon env this way.
657
- async function runPm(args: string[], env?: Record<string, string>): Promise<number> {
658
- const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
664
+ // Read the installed oxmgr's version (e.g. "0.4.0+winfix"), or null if oxmgr
665
+ // isn't on PATH / doesn't answer. Cached daemonManager() sits on the hot path
666
+ // of several subcommands (status, setup, install). Synchronous by design so the
667
+ // selection has no await threading through call sites.
668
+ let _oxmgrVersion: string | null | undefined;
669
+ function oxmgrVersion(bin: string): string | null {
670
+ if (_oxmgrVersion !== undefined) return _oxmgrVersion;
671
+ try {
672
+ const p = Bun.spawnSync([bin, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
673
+ const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(p.stdout?.toString() ?? "");
674
+ _oxmgrVersion = m ? m[1]! : null;
675
+ } catch {
676
+ _oxmgrVersion = null;
677
+ }
678
+ return _oxmgrVersion;
679
+ }
680
+
681
+ // Resolve (and cache) the process manager that daemonizes `rech serve`. The
682
+ // selection policy — oxmgr default, pm2 fallback, winfix-guarded on Windows,
683
+ // RECH_DAEMON_MANAGER override — lives in ./daemon-manager.js (pure + tested);
684
+ // here we just supply the runtime inputs (what's on PATH, the oxmgr version).
685
+ let _daemonMgr: DaemonManager | undefined;
686
+ function daemonManager(): DaemonManager {
687
+ if (_daemonMgr) return _daemonMgr;
688
+ const oxmgrBin = Bun.which("oxmgr");
689
+ const pm2Bin = Bun.which("pm2");
690
+ _daemonMgr = pickDaemonManager({
691
+ oxmgrBin,
692
+ pm2Bin,
693
+ oxmgrVersion: oxmgrBin ? oxmgrVersion(oxmgrBin) : null,
694
+ isWindows: IS_WINDOWS,
695
+ override: process.env.RECH_DAEMON_MANAGER,
696
+ });
697
+ return _daemonMgr;
698
+ }
699
+
700
+ // Spawn the resolved process manager by its absolute path (Bun.which). `env` is
701
+ // merged over process.env for the child: pm2 captures the CLI's environment for
702
+ // the managed process (it has no per-var flag like oxmgr's --env), so install
703
+ // passes daemon env this way.
704
+ async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
705
+ const proc = Bun.spawn([mgr.bin, ...args], {
659
706
  stdout: "inherit",
660
707
  stderr: "inherit",
708
+ windowsHide: true, // no console-window flash for the manager child on Windows
661
709
  ...(env ? { env: { ...process.env, ...env } } : {}),
662
710
  });
663
711
  await proc.exited;
664
712
  return proc.exitCode ?? 1;
665
713
  }
666
714
 
715
+ // oxmgr boot/login autostart. `service install` wires the platform init
716
+ // integration (Windows Task Scheduler, macOS launchd, or a systemd --user unit)
717
+ // so the daemon — and the managed serve with it — returns at login/boot, the way
718
+ // the pm2 path relied on `pm2 resurrect`. Skipped when already installed:
719
+ // re-running `service install` re-bootstraps the oxmgr daemon, which restarts
720
+ // every managed process (including the live serve). Best-effort — a failure
721
+ // leaves serve crash-managed but not login-persistent.
722
+ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
723
+ let alreadyInstalled = false;
724
+ try {
725
+ alreadyInstalled =
726
+ Bun.spawnSync([mgr.bin, "service", "status"], { stdout: "ignore", stderr: "ignore", windowsHide: true }).exitCode === 0;
727
+ } catch { /* treat a probe failure as not-installed and attempt install */ }
728
+ if (alreadyInstalled) return;
729
+ await runPm(mgr, ["service", "install"]);
730
+ }
731
+
667
732
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
668
733
  // Both render the process name verbatim, so callers can substring-match it.
669
- async function pmList(): Promise<string> {
670
- const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore" });
734
+ async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
735
+ const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
671
736
  return await new Response(proc.stdout).text();
672
737
  }
673
738
 
@@ -729,25 +794,27 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
729
794
  if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
730
795
  if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
731
796
 
797
+ const mgr = daemonManager();
798
+
732
799
  // Drop any prior registration (current + legacy names) before re-adding.
733
- for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
800
+ for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
734
801
 
735
802
  let startCode: number;
736
- if (IS_WINDOWS) {
803
+ if (mgr.id === "pm2") {
737
804
  // pm2 captures the CLI env (passed via runPm's env) for the managed process,
738
805
  // autorestarts by default, and runs the bun binary directly with
739
806
  // `--interpreter none` (so it isn't fed to node).
740
- startCode = await runPm([
807
+ startCode = await runPm(mgr, [
741
808
  "start", bunBin,
742
809
  "--name", PM_PROCESS_NAME,
743
810
  "--interpreter", "none",
744
811
  "--cwd", home,
745
812
  "--", rechScript, "serve",
746
813
  ], daemonEnv);
747
- await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
814
+ await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
748
815
  } else {
749
816
  const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
750
- startCode = await runPm([
817
+ startCode = await runPm(mgr, [
751
818
  "start",
752
819
  "--name", PM_PROCESS_NAME,
753
820
  "--restart", "always",
@@ -755,18 +822,23 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
755
822
  ...envArgs,
756
823
  `${bunBin} ${rechScript} serve`,
757
824
  ]);
758
- await runPm(["service", "install"]);
825
+ // Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
826
+ // on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
827
+ // path's `pm2 resurrect` at login. Guarded so a re-install doesn't bounce the
828
+ // live daemon.
829
+ await oxmgrEnsureAutostart(mgr);
759
830
  }
760
831
  // Surface a failed start instead of reporting a daemon that was never registered.
761
832
  if (startCode !== 0)
762
- throw new Error(`${PM_BIN} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${PM_BIN} is installed and on PATH.`);
833
+ throw new Error(`${mgr.id} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${mgr.id} is installed and on PATH.`);
763
834
  }
764
835
 
765
836
  async function daemonUninstall(): Promise<void> {
766
- for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
767
- if (IS_WINDOWS) await runPm(["save"]);
768
- else await runPm(["service", "uninstall"]);
769
- console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
837
+ const mgr = daemonManager();
838
+ for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
839
+ if (mgr.id === "pm2") await runPm(mgr, ["save"]);
840
+ else await runPm(mgr, ["service", "uninstall"]);
841
+ console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
770
842
  }
771
843
 
772
844
  // ── Native tray (menu-bar / system-tray) icon ───────────────────────────────
@@ -983,7 +1055,7 @@ async function provisionExtensionToken(opts: {
983
1055
  if (!opts.headed) args.push("--headless=new");
984
1056
  if (process.platform === "linux") args.push("--no-sandbox");
985
1057
  args.push("about:blank");
986
- const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
1058
+ const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
987
1059
  let cdp: CDPClient | null = null;
988
1060
  try {
989
1061
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
@@ -1198,13 +1270,34 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1198
1270
  );
1199
1271
  if (opts.profile !== undefined) {
1200
1272
  const num = parseInt(opts.profile);
1201
- if (!isNaN(num) && String(num) === opts.profile.trim()) return available[num - 1] ?? null;
1273
+ if (!isNaN(num) && String(num) === opts.profile.trim()) {
1274
+ // A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
1275
+ // "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
1276
+ // *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
1277
+ // and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
1278
+ // selection either way. Email is the unambiguous selector — steer toward it.
1279
+ const sel = available[num - 1] ?? null;
1280
+ const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
1281
+ if (dirNamed && dirNamed[0] !== sel?.[0]) {
1282
+ const hint = dirNamed[1].user_name || `"Profile ${num}"`;
1283
+ console.error(
1284
+ ` [warn] --profile ${num} = menu index ${num} → ` +
1285
+ `${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
1286
+ `NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
1287
+ `For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
1288
+ );
1289
+ }
1290
+ if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
1291
+ return sel;
1292
+ }
1202
1293
  const needle = opts.profile.toLowerCase();
1203
- return available.find(([dir, info]) =>
1294
+ const match = available.find(([dir, info]) =>
1204
1295
  dir.toLowerCase() === needle
1205
1296
  || (info.name ?? "").toLowerCase() === needle
1206
1297
  || (info.user_name ?? "").toLowerCase().includes(needle)
1207
1298
  ) ?? null;
1299
+ if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
1300
+ return match;
1208
1301
  }
1209
1302
  if (available.length === 1) {
1210
1303
  console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
@@ -1414,17 +1507,20 @@ async function status(): Promise<void> {
1414
1507
  const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
1415
1508
  // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
1416
1509
  // POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
1417
- const bind = ping
1510
+ const pingBody = ping
1418
1511
  ? await fetch(`${protocol}://${host}:${port}/ping`, {
1419
1512
  headers: { Authorization: `Bearer ${parsed.key}` },
1420
1513
  signal: AbortSignal.timeout(2000),
1421
- }).then(r => (r.ok ? r.json() : null)).then((b: { bind?: string } | null) => b?.bind).catch(() => undefined)
1422
- : undefined;
1514
+ }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
1515
+ : null;
1516
+ const bind = pingBody?.bind;
1423
1517
  const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
1424
1518
  console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
1519
+ if (pingBody?.degraded)
1520
+ console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${PM_BIN} restart ${PM_PROCESS_NAME}\``);
1425
1521
  const pmOut = await pmList();
1426
1522
  const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
1427
- console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
1523
+ console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
1428
1524
  const registry = await readTokenRegistry();
1429
1525
  const entries = Object.entries(registry);
1430
1526
  if (entries.length) {
@@ -1448,7 +1544,12 @@ function printHelp(): void {
1448
1544
  Usage:
1449
1545
  rech setup [--profile <num|email>] [--token <tok>]
1450
1546
  First-time setup: daemon + Chrome extension + config
1451
- --profile selects the Chrome profile non-interactively
1547
+ --profile selects the Chrome profile non-interactively.
1548
+ A bare number is the 1-based MENU INDEX (position in the
1549
+ listed order), NOT the Chrome directory "Profile N". For
1550
+ scripts prefer the email (e.g. --profile you@gmail.com) —
1551
+ it is the only unambiguous selector; an exact directory
1552
+ name ("Profile 1") also matches.
1452
1553
  --token (or RECH_TOKEN) supplies the auth token for
1453
1554
  non-TTY/agent runs, skipping the interactive paste
1454
1555
  rech provision-profile <name> --experimental [--headed]
package/rech.ts CHANGED
@@ -6,6 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
6
6
  import { hostname, homedir } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
  import { spawn as cpSpawn } from "child_process";
9
+ import { pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
9
10
 
10
11
  export const ENV_KEY = "RECHROME_URL";
11
12
  export const DEFAULT_PORT = 13775;
@@ -104,7 +105,7 @@ function openInDefaultApp(target: string): void {
104
105
  const cmd = process.platform === "darwin" ? ["open", target]
105
106
  : process.platform === "win32" ? ["cmd", "/c", "start", "", target]
106
107
  : ["xdg-open", target];
107
- try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
108
+ try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true }); } catch {}
108
109
  }
109
110
 
110
111
  // Best-effort path to the Chrome executable for the current platform (used to open a
@@ -137,7 +138,7 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
137
138
  try {
138
139
  Bun.spawn(
139
140
  [chromeBin, `--profile-directory=${profileDir}`, target],
140
- { stdout: "ignore", stderr: "ignore", detached: true },
141
+ { stdout: "ignore", stderr: "ignore", detached: true, windowsHide: true },
141
142
  );
142
143
  return true;
143
144
  } catch {
@@ -200,7 +201,8 @@ function realpathSafe(p: string): string {
200
201
 
201
202
  async function gitOutput(args: string[], cwd: string): Promise<string | null> {
202
203
  try {
203
- const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
204
+ // windowsHide: don't flash a console window on Windows (git.exe is a console app)
205
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore", windowsHide: true });
204
206
  const out = (await new Response(proc.stdout).text()).trim();
205
207
  await proc.exited;
206
208
  return out || null;
@@ -478,12 +480,18 @@ async function listProfiles(): Promise<void> {
478
480
  }
479
481
  }
480
482
 
481
- const rows = Object.entries(cache).map(([dir, info]) => [
482
- dir,
483
- info.user_name || "",
484
- info.name || "",
485
- dir === currentDir ? " current" : "",
486
- ]);
483
+ // Header clarifies each column; email/nickname sit beside the profile dir so a bare
484
+ // "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
485
+ // from Local State — the gaia real name is deliberately never surfaced.
486
+ const rows = [
487
+ ["PROFILE", "EMAIL", "NICKNAME", ""],
488
+ ...Object.entries(cache).map(([dir, info]) => [
489
+ dir,
490
+ info.user_name || "",
491
+ info.name || "",
492
+ dir === currentDir ? "← current" : "",
493
+ ]),
494
+ ];
487
495
  const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
488
496
  for (const row of rows) {
489
497
  console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
@@ -494,9 +502,13 @@ async function callServe(
494
502
  url: string,
495
503
  args: string[],
496
504
  overrideEnv?: Record<string, string>,
505
+ precomputedIdentity?: { key: string; label: string; profile?: string },
497
506
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
498
507
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
499
- const identity = await getClientIdentity();
508
+ // Reuse the caller's identity when provided — computing it shells out to `git` several times,
509
+ // and run() has already done so for its log line. Recomputing here would double those git
510
+ // spawns (and, on Windows, the console-window flashes) on every `rech open`.
511
+ const identity = precomputedIdentity ?? await getClientIdentity();
500
512
  const effectiveProfile = resolveEffectiveProfile(profileDirectory);
501
513
  if (effectiveProfile) identity.profile = effectiveProfile;
502
514
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
@@ -545,7 +557,7 @@ async function run(url: string, args: string[]) {
545
557
  );
546
558
 
547
559
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
548
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
560
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
549
561
 
550
562
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
551
563
  if (existingSession && isOpenWithUrl) {
@@ -647,27 +659,80 @@ const PM_PROCESS_NAME = "rechrome";
647
659
  // Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
648
660
  // migrates an existing checkout cleanly.
649
661
  const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
650
- // oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
651
662
  const IS_WINDOWS = process.platform === "win32";
652
- const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
653
663
 
654
- // Spawn the active process manager. `env` is merged over process.env for the
655
- // child: pm2 captures the CLI's environment for the managed process (it has no
656
- // per-var flag like oxmgr's --env), so install passes daemon env this way.
657
- async function runPm(args: string[], env?: Record<string, string>): Promise<number> {
658
- const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
664
+ // Read the installed oxmgr's version (e.g. "0.4.0+winfix"), or null if oxmgr
665
+ // isn't on PATH / doesn't answer. Cached daemonManager() sits on the hot path
666
+ // of several subcommands (status, setup, install). Synchronous by design so the
667
+ // selection has no await threading through call sites.
668
+ let _oxmgrVersion: string | null | undefined;
669
+ function oxmgrVersion(bin: string): string | null {
670
+ if (_oxmgrVersion !== undefined) return _oxmgrVersion;
671
+ try {
672
+ const p = Bun.spawnSync([bin, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
673
+ const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(p.stdout?.toString() ?? "");
674
+ _oxmgrVersion = m ? m[1]! : null;
675
+ } catch {
676
+ _oxmgrVersion = null;
677
+ }
678
+ return _oxmgrVersion;
679
+ }
680
+
681
+ // Resolve (and cache) the process manager that daemonizes `rech serve`. The
682
+ // selection policy — oxmgr default, pm2 fallback, winfix-guarded on Windows,
683
+ // RECH_DAEMON_MANAGER override — lives in ./daemon-manager.ts (pure + tested);
684
+ // here we just supply the runtime inputs (what's on PATH, the oxmgr version).
685
+ let _daemonMgr: DaemonManager | undefined;
686
+ function daemonManager(): DaemonManager {
687
+ if (_daemonMgr) return _daemonMgr;
688
+ const oxmgrBin = Bun.which("oxmgr");
689
+ const pm2Bin = Bun.which("pm2");
690
+ _daemonMgr = pickDaemonManager({
691
+ oxmgrBin,
692
+ pm2Bin,
693
+ oxmgrVersion: oxmgrBin ? oxmgrVersion(oxmgrBin) : null,
694
+ isWindows: IS_WINDOWS,
695
+ override: process.env.RECH_DAEMON_MANAGER,
696
+ });
697
+ return _daemonMgr;
698
+ }
699
+
700
+ // Spawn the resolved process manager by its absolute path (Bun.which). `env` is
701
+ // merged over process.env for the child: pm2 captures the CLI's environment for
702
+ // the managed process (it has no per-var flag like oxmgr's --env), so install
703
+ // passes daemon env this way.
704
+ async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
705
+ const proc = Bun.spawn([mgr.bin, ...args], {
659
706
  stdout: "inherit",
660
707
  stderr: "inherit",
708
+ windowsHide: true, // no console-window flash for the manager child on Windows
661
709
  ...(env ? { env: { ...process.env, ...env } } : {}),
662
710
  });
663
711
  await proc.exited;
664
712
  return proc.exitCode ?? 1;
665
713
  }
666
714
 
715
+ // oxmgr boot/login autostart. `service install` wires the platform init
716
+ // integration (Windows Task Scheduler, macOS launchd, or a systemd --user unit)
717
+ // so the daemon — and the managed serve with it — returns at login/boot, the way
718
+ // the pm2 path relied on `pm2 resurrect`. Skipped when already installed:
719
+ // re-running `service install` re-bootstraps the oxmgr daemon, which restarts
720
+ // every managed process (including the live serve). Best-effort — a failure
721
+ // leaves serve crash-managed but not login-persistent.
722
+ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
723
+ let alreadyInstalled = false;
724
+ try {
725
+ alreadyInstalled =
726
+ Bun.spawnSync([mgr.bin, "service", "status"], { stdout: "ignore", stderr: "ignore", windowsHide: true }).exitCode === 0;
727
+ } catch { /* treat a probe failure as not-installed and attempt install */ }
728
+ if (alreadyInstalled) return;
729
+ await runPm(mgr, ["service", "install"]);
730
+ }
731
+
667
732
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
668
733
  // Both render the process name verbatim, so callers can substring-match it.
669
- async function pmList(): Promise<string> {
670
- const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore" });
734
+ async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
735
+ const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
671
736
  return await new Response(proc.stdout).text();
672
737
  }
673
738
 
@@ -729,25 +794,27 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
729
794
  if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
730
795
  if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
731
796
 
797
+ const mgr = daemonManager();
798
+
732
799
  // Drop any prior registration (current + legacy names) before re-adding.
733
- for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
800
+ for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
734
801
 
735
802
  let startCode: number;
736
- if (IS_WINDOWS) {
803
+ if (mgr.id === "pm2") {
737
804
  // pm2 captures the CLI env (passed via runPm's env) for the managed process,
738
805
  // autorestarts by default, and runs the bun binary directly with
739
806
  // `--interpreter none` (so it isn't fed to node).
740
- startCode = await runPm([
807
+ startCode = await runPm(mgr, [
741
808
  "start", bunBin,
742
809
  "--name", PM_PROCESS_NAME,
743
810
  "--interpreter", "none",
744
811
  "--cwd", home,
745
812
  "--", rechScript, "serve",
746
813
  ], daemonEnv);
747
- await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
814
+ await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
748
815
  } else {
749
816
  const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
750
- startCode = await runPm([
817
+ startCode = await runPm(mgr, [
751
818
  "start",
752
819
  "--name", PM_PROCESS_NAME,
753
820
  "--restart", "always",
@@ -755,18 +822,23 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
755
822
  ...envArgs,
756
823
  `${bunBin} ${rechScript} serve`,
757
824
  ]);
758
- await runPm(["service", "install"]);
825
+ // Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
826
+ // on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
827
+ // path's `pm2 resurrect` at login. Guarded so a re-install doesn't bounce the
828
+ // live daemon.
829
+ await oxmgrEnsureAutostart(mgr);
759
830
  }
760
831
  // Surface a failed start instead of reporting a daemon that was never registered.
761
832
  if (startCode !== 0)
762
- throw new Error(`${PM_BIN} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${PM_BIN} is installed and on PATH.`);
833
+ throw new Error(`${mgr.id} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${mgr.id} is installed and on PATH.`);
763
834
  }
764
835
 
765
836
  async function daemonUninstall(): Promise<void> {
766
- for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
767
- if (IS_WINDOWS) await runPm(["save"]);
768
- else await runPm(["service", "uninstall"]);
769
- console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
837
+ const mgr = daemonManager();
838
+ for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
839
+ if (mgr.id === "pm2") await runPm(mgr, ["save"]);
840
+ else await runPm(mgr, ["service", "uninstall"]);
841
+ console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
770
842
  }
771
843
 
772
844
  // ── Native tray (menu-bar / system-tray) icon ───────────────────────────────
@@ -983,7 +1055,7 @@ async function provisionExtensionToken(opts: {
983
1055
  if (!opts.headed) args.push("--headless=new");
984
1056
  if (process.platform === "linux") args.push("--no-sandbox");
985
1057
  args.push("about:blank");
986
- const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
1058
+ const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
987
1059
  let cdp: CDPClient | null = null;
988
1060
  try {
989
1061
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
@@ -1198,13 +1270,34 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1198
1270
  );
1199
1271
  if (opts.profile !== undefined) {
1200
1272
  const num = parseInt(opts.profile);
1201
- if (!isNaN(num) && String(num) === opts.profile.trim()) return available[num - 1] ?? null;
1273
+ if (!isNaN(num) && String(num) === opts.profile.trim()) {
1274
+ // A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
1275
+ // "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
1276
+ // *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
1277
+ // and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
1278
+ // selection either way. Email is the unambiguous selector — steer toward it.
1279
+ const sel = available[num - 1] ?? null;
1280
+ const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
1281
+ if (dirNamed && dirNamed[0] !== sel?.[0]) {
1282
+ const hint = dirNamed[1].user_name || `"Profile ${num}"`;
1283
+ console.error(
1284
+ ` [warn] --profile ${num} = menu index ${num} → ` +
1285
+ `${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
1286
+ `NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
1287
+ `For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
1288
+ );
1289
+ }
1290
+ if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
1291
+ return sel;
1292
+ }
1202
1293
  const needle = opts.profile.toLowerCase();
1203
- return available.find(([dir, info]) =>
1294
+ const match = available.find(([dir, info]) =>
1204
1295
  dir.toLowerCase() === needle
1205
1296
  || (info.name ?? "").toLowerCase() === needle
1206
1297
  || (info.user_name ?? "").toLowerCase().includes(needle)
1207
1298
  ) ?? null;
1299
+ if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
1300
+ return match;
1208
1301
  }
1209
1302
  if (available.length === 1) {
1210
1303
  console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
@@ -1414,17 +1507,20 @@ async function status(): Promise<void> {
1414
1507
  const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
1415
1508
  // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
1416
1509
  // POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
1417
- const bind = ping
1510
+ const pingBody = ping
1418
1511
  ? await fetch(`${protocol}://${host}:${port}/ping`, {
1419
1512
  headers: { Authorization: `Bearer ${parsed.key}` },
1420
1513
  signal: AbortSignal.timeout(2000),
1421
- }).then(r => (r.ok ? r.json() : null)).then((b: { bind?: string } | null) => b?.bind).catch(() => undefined)
1422
- : undefined;
1514
+ }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
1515
+ : null;
1516
+ const bind = pingBody?.bind;
1423
1517
  const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
1424
1518
  console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
1519
+ if (pingBody?.degraded)
1520
+ console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${PM_BIN} restart ${PM_PROCESS_NAME}\``);
1425
1521
  const pmOut = await pmList();
1426
1522
  const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
1427
- console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
1523
+ console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
1428
1524
  const registry = await readTokenRegistry();
1429
1525
  const entries = Object.entries(registry);
1430
1526
  if (entries.length) {
@@ -1448,7 +1544,12 @@ function printHelp(): void {
1448
1544
  Usage:
1449
1545
  rech setup [--profile <num|email>] [--token <tok>]
1450
1546
  First-time setup: daemon + Chrome extension + config
1451
- --profile selects the Chrome profile non-interactively
1547
+ --profile selects the Chrome profile non-interactively.
1548
+ A bare number is the 1-based MENU INDEX (position in the
1549
+ listed order), NOT the Chrome directory "Profile N". For
1550
+ scripts prefer the email (e.g. --profile you@gmail.com) —
1551
+ it is the only unambiguous selector; an exact directory
1552
+ name ("Profile 1") also matches.
1452
1553
  --token (or RECH_TOKEN) supplies the auth token for
1453
1554
  non-TTY/agent runs, skipping the interactive paste
1454
1555
  rech provision-profile <name> --experimental [--headed]
package/serve.js CHANGED
@@ -55,6 +55,35 @@ export function isIsoSession(namespacedSession: string): boolean {
55
55
  return /(?:^|-)iso-[0-9a-f]+$/.test(namespacedSession);
56
56
  }
57
57
 
58
+ // --- Relay health / self-heal ---------------------------------------------------------
59
+ // The daemon spawns a short-lived CLI child per command, but all commands share a long-lived
60
+ // per-session cliDaemon and a single extension↔relay path. Under sustained multi-session load
61
+ // the relay can wedge: navigations (`open`) hang to the 60s cap while cheap calls still return,
62
+ // and — critically — the wedge PERSISTS (every later command reuses the same broken cliDaemon),
63
+ // so historically only a manual `oxmgr restart rechrome` cleared it. We heal automatically on
64
+ // two layers, driven by real command timeouts (no synthetic probe → Chrome is never touched):
65
+ // - per-session: after SESSION_CLOSE_TIMEOUTS consecutive timeouts on ONE session, run the
66
+ // CLI `close` for it so the next command respawns a clean cliDaemon.
67
+ // - global: after WATCHDOG_TIMEOUTS consecutive timeouts across ALL sessions with no success
68
+ // in between (= the shared relay is dead), exit(1); oxmgr's `--restart always` respawns us
69
+ // clean and the extension WS reconnects. Each timeout burns 60s of wall time, so this can't
70
+ // spin faster than ~once/minute even under concurrent load.
71
+ const SESSION_CLOSE_TIMEOUTS = Number(process.env.RECH_SESSION_CLOSE_TIMEOUTS) || 2;
72
+ const WATCHDOG_TIMEOUTS = Number(process.env.RECH_WATCHDOG_TIMEOUTS) || 3;
73
+ let consecutiveTimeouts = 0; // global; reset on any non-timeout /run
74
+ const sessionTimeouts = new Map<string, number>(); // per-session consecutive-timeout streak
75
+ // Most-recently-used non-iso sessions, so the deep health probe can target something real
76
+ // instead of spawning a fresh session (which could open a browser window).
77
+ const recentSessions = new Map<string, number>();
78
+ function noteSession(sess: string, now: number): void {
79
+ recentSessions.set(sess, now);
80
+ if (recentSessions.size > 64) {
81
+ let oldestKey: string | undefined, oldestAt = Infinity;
82
+ for (const [k, t] of recentSessions) if (t < oldestAt) { oldestAt = t; oldestKey = k; }
83
+ if (oldestKey) recentSessions.delete(oldestKey);
84
+ }
85
+ }
86
+
58
87
  function tmpSocketRoot(): string {
59
88
  return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
60
89
  }
@@ -92,6 +121,7 @@ function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): v
92
121
  stdin: "ignore",
93
122
  stdout: "ignore",
94
123
  stderr: "ignore",
124
+ windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
95
125
  env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
96
126
  });
97
127
  log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
@@ -112,7 +142,7 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
112
142
  if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
113
143
  log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
114
144
  const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
115
- stdout: "pipe", stderr: "pipe",
145
+ stdout: "pipe", stderr: "pipe", windowsHide: true,
116
146
  });
117
147
  const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
118
148
  if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
@@ -189,7 +219,7 @@ async function freeStalePort(port: number): Promise<void> {
189
219
  " $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
190
220
  "}",
191
221
  ].join(" ");
192
- const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
222
+ const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
193
223
  const out = r.stdout?.toString().trim();
194
224
  if (out) log(out);
195
225
  } else {
@@ -228,9 +258,13 @@ export async function serve() {
228
258
  }, 86_400_000);
229
259
  }
230
260
  const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
231
- const startServer = () => Bun.serve({
261
+ const startServer = (reusePort = false) => Bun.serve({
232
262
  hostname: listenHost,
233
263
  port,
264
+ // reusePort is used only as a last-resort fallback (see the bind loop below): if an orphaned
265
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
266
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
267
+ reusePort,
234
268
  tls,
235
269
  error(err) {
236
270
  log(`unhandled error: ${err.message}`);
@@ -254,7 +288,32 @@ export async function serve() {
254
288
  if (reqUrl.pathname === "/ping") {
255
289
  const denied = authCheck(req, key);
256
290
  if (denied) return denied;
257
- return Response.json({ ok: true, bind: listenHost });
291
+ // Shallow ping proves only that the HTTP listener is up (it stayed up through past
292
+ // relay wedges). `degraded` surfaces the passive timeout streak from real traffic so
293
+ // clients / `rech status` can see trouble without an active probe.
294
+ const degraded = consecutiveTimeouts > 0;
295
+ if (!reqUrl.searchParams.get("deep"))
296
+ return Response.json({ ok: true, bind: listenHost, consecutiveTimeouts, degraded });
297
+ // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
298
+ // most-recently-used session — never a fresh one, which could spawn a browser window.
299
+ const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
300
+ if (!target)
301
+ return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded });
302
+ const [pbin, ...pbinArgs] = splitCommand(resolvePlaywrightCli());
303
+ const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
304
+ cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
305
+ windowsHide: true,
306
+ env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
307
+ });
308
+ const probeStatus = await Promise.race([
309
+ probe.exited,
310
+ new Promise<number>((r) => setTimeout(() => { probe.kill(); r(-1); }, 5000)),
311
+ ]);
312
+ const healthy = probeStatus !== -1;
313
+ return Response.json({
314
+ ok: healthy, bind: listenHost, relay: healthy ? "healthy" : "degraded",
315
+ consecutiveTimeouts, degraded: degraded || !healthy,
316
+ });
258
317
  }
259
318
  if (reqUrl.pathname !== "/run") return new Response("rech server\n");
260
319
  const denied = authCheck(req, key);
@@ -312,7 +371,9 @@ export async function serve() {
312
371
  });
313
372
  const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
314
373
  // Track --isolate sessions so the idle-TTL reaper can close them later.
315
- if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, Date.now());
374
+ const nowMs = Date.now();
375
+ if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, nowMs);
376
+ else noteSession(namespacedSession, nowMs); // for the deep health probe to target
316
377
 
317
378
  // daemonInstall bakes PLAYWRIGHT_CLI into the daemon env; resolvePlaywrightCli() is the
318
379
  // fallback for a standalone `serve` (it re-runs the same env > fork > @playwright/cli > legacy chain).
@@ -339,6 +400,7 @@ export async function serve() {
339
400
  stdin: "ignore",
340
401
  stdout: "pipe",
341
402
  stderr: "pipe",
403
+ windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
342
404
  env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
343
405
  });
344
406
  const [listStatus, listOut] = await Promise.race([
@@ -421,16 +483,20 @@ export async function serve() {
421
483
  stdin: "ignore",
422
484
  stdout: "pipe",
423
485
  stderr: "pipe",
486
+ windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
424
487
  env: childEnv,
425
488
  });
426
489
 
427
490
  const TIMEOUT = 60_000;
428
- const timeout = new Promise<never>((_, reject) =>
429
- setTimeout(() => {
491
+ let timedOut = false;
492
+ let timer: ReturnType<typeof setTimeout> | undefined;
493
+ const timeout = new Promise<never>((_, reject) => {
494
+ timer = setTimeout(() => {
495
+ timedOut = true;
430
496
  proc.kill();
431
497
  reject(new Error("timeout"));
432
- }, TIMEOUT),
433
- );
498
+ }, TIMEOUT);
499
+ });
434
500
  const [status, stdout, stderr] = await Promise.race([
435
501
  Promise.all([
436
502
  proc.exited,
@@ -441,9 +507,37 @@ export async function serve() {
441
507
  ]).catch(
442
508
  () => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
443
509
  ) as [number, string, string];
510
+ clearTimeout(timer);
444
511
 
445
512
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
446
513
 
514
+ // Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
515
+ // non-zero — proves the relay answered, so clear the streaks; a TIMEOUT means it didn't.
516
+ if (timedOut) {
517
+ consecutiveTimeouts++;
518
+ const streak = (sessionTimeouts.get(namespacedSession) ?? 0) + 1;
519
+ sessionTimeouts.set(namespacedSession, streak);
520
+ log(`timeout: session=${namespacedSession} sessionStreak=${streak} globalStreak=${consecutiveTimeouts}`);
521
+ if (streak >= SESSION_CLOSE_TIMEOUTS) {
522
+ log(`session ${namespacedSession} wedged (${streak} consecutive timeouts) — closing so the next command respawns a clean cliDaemon`);
523
+ try {
524
+ Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
525
+ cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
526
+ windowsHide: true,
527
+ env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
528
+ });
529
+ } catch {}
530
+ sessionTimeouts.delete(namespacedSession);
531
+ }
532
+ if (consecutiveTimeouts >= WATCHDOG_TIMEOUTS) {
533
+ log(`relay wedged: ${consecutiveTimeouts} consecutive timeouts, no success between — exiting for oxmgr (--restart always) to respawn clean; extension WS will reconnect (Chrome untouched)`);
534
+ setTimeout(() => process.exit(1), 100); // brief delay to flush this response
535
+ }
536
+ } else {
537
+ consecutiveTimeouts = 0;
538
+ sessionTimeouts.delete(namespacedSession);
539
+ }
540
+
447
541
  // Detect files mentioned in output
448
542
  const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
449
543
  const mentionedFiles = [
@@ -482,17 +576,34 @@ export async function serve() {
482
576
  },
483
577
  });
484
578
 
485
- // A leaked listening-socket handle in an orphaned cliDaemon can keep the port held after a
486
- // prior serve exits; on EADDRINUSE, clear stale holders once and retry rather than crash-loop.
487
- let server: ReturnType<typeof startServer>;
488
- try {
489
- server = startServer();
490
- } catch (e: any) {
491
- if (!String(e?.code ?? e?.message ?? "").includes("EADDRINUSE")) throw e;
492
- log(`port ${port} in use clearing stale daemon holders and retrying`);
493
- await freeStalePort(port);
494
- server = startServer();
579
+ // A leaked listening-socket handle in an orphaned cliDaemon can keep the port in LISTEN after a
580
+ // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
581
+ // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
582
+ // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
583
+ // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
584
+ // Retry a bounded number of times: the old single retry crash-looped whenever the OS hadn't yet
585
+ // released the socket after the kill (pm2 then restarts serve into the same race). As an absolute
586
+ // last resort, bind with reusePort so a holder we genuinely can't kill degrades to "up but sharing
587
+ // the port" rather than a permanent EADDRINUSE crash-loop.
588
+ const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
589
+ const MAX_BIND_ATTEMPTS = 4;
590
+ let server: ReturnType<typeof startServer> | undefined;
591
+ for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
592
+ try {
593
+ server = startServer();
594
+ break;
595
+ } catch (e: any) {
596
+ if (!isEaddrInUse(e)) throw e;
597
+ if (attempt === MAX_BIND_ATTEMPTS) {
598
+ log(`port ${port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
599
+ server = startServer(true);
600
+ break;
601
+ }
602
+ log(`port ${port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
603
+ await freeStalePort(port);
604
+ }
495
605
  }
606
+ if (!server) throw new Error(`failed to bind port ${port}`);
496
607
 
497
608
  log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
498
609
  log(`Connection URL set (use .env.local to view)`);
package/serve.ts CHANGED
@@ -55,6 +55,35 @@ export function isIsoSession(namespacedSession: string): boolean {
55
55
  return /(?:^|-)iso-[0-9a-f]+$/.test(namespacedSession);
56
56
  }
57
57
 
58
+ // --- Relay health / self-heal ---------------------------------------------------------
59
+ // The daemon spawns a short-lived CLI child per command, but all commands share a long-lived
60
+ // per-session cliDaemon and a single extension↔relay path. Under sustained multi-session load
61
+ // the relay can wedge: navigations (`open`) hang to the 60s cap while cheap calls still return,
62
+ // and — critically — the wedge PERSISTS (every later command reuses the same broken cliDaemon),
63
+ // so historically only a manual `oxmgr restart rechrome` cleared it. We heal automatically on
64
+ // two layers, driven by real command timeouts (no synthetic probe → Chrome is never touched):
65
+ // - per-session: after SESSION_CLOSE_TIMEOUTS consecutive timeouts on ONE session, run the
66
+ // CLI `close` for it so the next command respawns a clean cliDaemon.
67
+ // - global: after WATCHDOG_TIMEOUTS consecutive timeouts across ALL sessions with no success
68
+ // in between (= the shared relay is dead), exit(1); oxmgr's `--restart always` respawns us
69
+ // clean and the extension WS reconnects. Each timeout burns 60s of wall time, so this can't
70
+ // spin faster than ~once/minute even under concurrent load.
71
+ const SESSION_CLOSE_TIMEOUTS = Number(process.env.RECH_SESSION_CLOSE_TIMEOUTS) || 2;
72
+ const WATCHDOG_TIMEOUTS = Number(process.env.RECH_WATCHDOG_TIMEOUTS) || 3;
73
+ let consecutiveTimeouts = 0; // global; reset on any non-timeout /run
74
+ const sessionTimeouts = new Map<string, number>(); // per-session consecutive-timeout streak
75
+ // Most-recently-used non-iso sessions, so the deep health probe can target something real
76
+ // instead of spawning a fresh session (which could open a browser window).
77
+ const recentSessions = new Map<string, number>();
78
+ function noteSession(sess: string, now: number): void {
79
+ recentSessions.set(sess, now);
80
+ if (recentSessions.size > 64) {
81
+ let oldestKey: string | undefined, oldestAt = Infinity;
82
+ for (const [k, t] of recentSessions) if (t < oldestAt) { oldestAt = t; oldestKey = k; }
83
+ if (oldestKey) recentSessions.delete(oldestKey);
84
+ }
85
+ }
86
+
58
87
  function tmpSocketRoot(): string {
59
88
  return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
60
89
  }
@@ -92,6 +121,7 @@ function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): v
92
121
  stdin: "ignore",
93
122
  stdout: "ignore",
94
123
  stderr: "ignore",
124
+ windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
95
125
  env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
96
126
  });
97
127
  log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
@@ -112,7 +142,7 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
112
142
  if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
113
143
  log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
114
144
  const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
115
- stdout: "pipe", stderr: "pipe",
145
+ stdout: "pipe", stderr: "pipe", windowsHide: true,
116
146
  });
117
147
  const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
118
148
  if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
@@ -189,7 +219,7 @@ async function freeStalePort(port: number): Promise<void> {
189
219
  " $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
190
220
  "}",
191
221
  ].join(" ");
192
- const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
222
+ const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
193
223
  const out = r.stdout?.toString().trim();
194
224
  if (out) log(out);
195
225
  } else {
@@ -228,9 +258,13 @@ export async function serve() {
228
258
  }, 86_400_000);
229
259
  }
230
260
  const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
231
- const startServer = () => Bun.serve({
261
+ const startServer = (reusePort = false) => Bun.serve({
232
262
  hostname: listenHost,
233
263
  port,
264
+ // reusePort is used only as a last-resort fallback (see the bind loop below): if an orphaned
265
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
266
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
267
+ reusePort,
234
268
  tls,
235
269
  error(err) {
236
270
  log(`unhandled error: ${err.message}`);
@@ -254,7 +288,32 @@ export async function serve() {
254
288
  if (reqUrl.pathname === "/ping") {
255
289
  const denied = authCheck(req, key);
256
290
  if (denied) return denied;
257
- return Response.json({ ok: true, bind: listenHost });
291
+ // Shallow ping proves only that the HTTP listener is up (it stayed up through past
292
+ // relay wedges). `degraded` surfaces the passive timeout streak from real traffic so
293
+ // clients / `rech status` can see trouble without an active probe.
294
+ const degraded = consecutiveTimeouts > 0;
295
+ if (!reqUrl.searchParams.get("deep"))
296
+ return Response.json({ ok: true, bind: listenHost, consecutiveTimeouts, degraded });
297
+ // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
298
+ // most-recently-used session — never a fresh one, which could spawn a browser window.
299
+ const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
300
+ if (!target)
301
+ return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded });
302
+ const [pbin, ...pbinArgs] = splitCommand(resolvePlaywrightCli());
303
+ const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
304
+ cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
305
+ windowsHide: true,
306
+ env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
307
+ });
308
+ const probeStatus = await Promise.race([
309
+ probe.exited,
310
+ new Promise<number>((r) => setTimeout(() => { probe.kill(); r(-1); }, 5000)),
311
+ ]);
312
+ const healthy = probeStatus !== -1;
313
+ return Response.json({
314
+ ok: healthy, bind: listenHost, relay: healthy ? "healthy" : "degraded",
315
+ consecutiveTimeouts, degraded: degraded || !healthy,
316
+ });
258
317
  }
259
318
  if (reqUrl.pathname !== "/run") return new Response("rech server\n");
260
319
  const denied = authCheck(req, key);
@@ -312,7 +371,9 @@ export async function serve() {
312
371
  });
313
372
  const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
314
373
  // Track --isolate sessions so the idle-TTL reaper can close them later.
315
- if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, Date.now());
374
+ const nowMs = Date.now();
375
+ if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, nowMs);
376
+ else noteSession(namespacedSession, nowMs); // for the deep health probe to target
316
377
 
317
378
  // daemonInstall bakes PLAYWRIGHT_CLI into the daemon env; resolvePlaywrightCli() is the
318
379
  // fallback for a standalone `serve` (it re-runs the same env > fork > @playwright/cli > legacy chain).
@@ -339,6 +400,7 @@ export async function serve() {
339
400
  stdin: "ignore",
340
401
  stdout: "pipe",
341
402
  stderr: "pipe",
403
+ windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
342
404
  env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
343
405
  });
344
406
  const [listStatus, listOut] = await Promise.race([
@@ -421,16 +483,20 @@ export async function serve() {
421
483
  stdin: "ignore",
422
484
  stdout: "pipe",
423
485
  stderr: "pipe",
486
+ windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
424
487
  env: childEnv,
425
488
  });
426
489
 
427
490
  const TIMEOUT = 60_000;
428
- const timeout = new Promise<never>((_, reject) =>
429
- setTimeout(() => {
491
+ let timedOut = false;
492
+ let timer: ReturnType<typeof setTimeout> | undefined;
493
+ const timeout = new Promise<never>((_, reject) => {
494
+ timer = setTimeout(() => {
495
+ timedOut = true;
430
496
  proc.kill();
431
497
  reject(new Error("timeout"));
432
- }, TIMEOUT),
433
- );
498
+ }, TIMEOUT);
499
+ });
434
500
  const [status, stdout, stderr] = await Promise.race([
435
501
  Promise.all([
436
502
  proc.exited,
@@ -441,9 +507,37 @@ export async function serve() {
441
507
  ]).catch(
442
508
  () => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
443
509
  ) as [number, string, string];
510
+ clearTimeout(timer);
444
511
 
445
512
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
446
513
 
514
+ // Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
515
+ // non-zero — proves the relay answered, so clear the streaks; a TIMEOUT means it didn't.
516
+ if (timedOut) {
517
+ consecutiveTimeouts++;
518
+ const streak = (sessionTimeouts.get(namespacedSession) ?? 0) + 1;
519
+ sessionTimeouts.set(namespacedSession, streak);
520
+ log(`timeout: session=${namespacedSession} sessionStreak=${streak} globalStreak=${consecutiveTimeouts}`);
521
+ if (streak >= SESSION_CLOSE_TIMEOUTS) {
522
+ log(`session ${namespacedSession} wedged (${streak} consecutive timeouts) — closing so the next command respawns a clean cliDaemon`);
523
+ try {
524
+ Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
525
+ cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
526
+ windowsHide: true,
527
+ env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
528
+ });
529
+ } catch {}
530
+ sessionTimeouts.delete(namespacedSession);
531
+ }
532
+ if (consecutiveTimeouts >= WATCHDOG_TIMEOUTS) {
533
+ log(`relay wedged: ${consecutiveTimeouts} consecutive timeouts, no success between — exiting for oxmgr (--restart always) to respawn clean; extension WS will reconnect (Chrome untouched)`);
534
+ setTimeout(() => process.exit(1), 100); // brief delay to flush this response
535
+ }
536
+ } else {
537
+ consecutiveTimeouts = 0;
538
+ sessionTimeouts.delete(namespacedSession);
539
+ }
540
+
447
541
  // Detect files mentioned in output
448
542
  const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
449
543
  const mentionedFiles = [
@@ -482,17 +576,34 @@ export async function serve() {
482
576
  },
483
577
  });
484
578
 
485
- // A leaked listening-socket handle in an orphaned cliDaemon can keep the port held after a
486
- // prior serve exits; on EADDRINUSE, clear stale holders once and retry rather than crash-loop.
487
- let server: ReturnType<typeof startServer>;
488
- try {
489
- server = startServer();
490
- } catch (e: any) {
491
- if (!String(e?.code ?? e?.message ?? "").includes("EADDRINUSE")) throw e;
492
- log(`port ${port} in use clearing stale daemon holders and retrying`);
493
- await freeStalePort(port);
494
- server = startServer();
579
+ // A leaked listening-socket handle in an orphaned cliDaemon can keep the port in LISTEN after a
580
+ // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
581
+ // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
582
+ // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
583
+ // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
584
+ // Retry a bounded number of times: the old single retry crash-looped whenever the OS hadn't yet
585
+ // released the socket after the kill (pm2 then restarts serve into the same race). As an absolute
586
+ // last resort, bind with reusePort so a holder we genuinely can't kill degrades to "up but sharing
587
+ // the port" rather than a permanent EADDRINUSE crash-loop.
588
+ const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
589
+ const MAX_BIND_ATTEMPTS = 4;
590
+ let server: ReturnType<typeof startServer> | undefined;
591
+ for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
592
+ try {
593
+ server = startServer();
594
+ break;
595
+ } catch (e: any) {
596
+ if (!isEaddrInUse(e)) throw e;
597
+ if (attempt === MAX_BIND_ATTEMPTS) {
598
+ log(`port ${port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
599
+ server = startServer(true);
600
+ break;
601
+ }
602
+ log(`port ${port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
603
+ await freeStalePort(port);
604
+ }
495
605
  }
606
+ if (!server) throw new Error(`failed to bind port ${port}`);
496
607
 
497
608
  log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
498
609
  log(`Connection URL set (use .env.local to view)`);