rechrome 1.21.0 → 1.22.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 +60 -19
  3. package/rech.ts +60 -19
  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.22.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -104,7 +104,7 @@ function openInDefaultApp(target: string): void {
104
104
  const cmd = process.platform === "darwin" ? ["open", target]
105
105
  : process.platform === "win32" ? ["cmd", "/c", "start", "", target]
106
106
  : ["xdg-open", target];
107
- try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
107
+ try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true }); } catch {}
108
108
  }
109
109
 
110
110
  // Best-effort path to the Chrome executable for the current platform (used to open a
@@ -137,7 +137,7 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
137
137
  try {
138
138
  Bun.spawn(
139
139
  [chromeBin, `--profile-directory=${profileDir}`, target],
140
- { stdout: "ignore", stderr: "ignore", detached: true },
140
+ { stdout: "ignore", stderr: "ignore", detached: true, windowsHide: true },
141
141
  );
142
142
  return true;
143
143
  } catch {
@@ -200,7 +200,8 @@ function realpathSafe(p: string): string {
200
200
 
201
201
  async function gitOutput(args: string[], cwd: string): Promise<string | null> {
202
202
  try {
203
- const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
203
+ // windowsHide: don't flash a console window on Windows (git.exe is a console app)
204
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore", windowsHide: true });
204
205
  const out = (await new Response(proc.stdout).text()).trim();
205
206
  await proc.exited;
206
207
  return out || null;
@@ -478,12 +479,18 @@ async function listProfiles(): Promise<void> {
478
479
  }
479
480
  }
480
481
 
481
- const rows = Object.entries(cache).map(([dir, info]) => [
482
- dir,
483
- info.user_name || "",
484
- info.name || "",
485
- dir === currentDir ? " current" : "",
486
- ]);
482
+ // Header clarifies each column; email/nickname sit beside the profile dir so a bare
483
+ // "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
484
+ // from Local State — the gaia real name is deliberately never surfaced.
485
+ const rows = [
486
+ ["PROFILE", "EMAIL", "NICKNAME", ""],
487
+ ...Object.entries(cache).map(([dir, info]) => [
488
+ dir,
489
+ info.user_name || "",
490
+ info.name || "",
491
+ dir === currentDir ? "← current" : "",
492
+ ]),
493
+ ];
487
494
  const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
488
495
  for (const row of rows) {
489
496
  console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
@@ -494,9 +501,13 @@ async function callServe(
494
501
  url: string,
495
502
  args: string[],
496
503
  overrideEnv?: Record<string, string>,
504
+ precomputedIdentity?: { key: string; label: string; profile?: string },
497
505
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
498
506
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
499
- const identity = await getClientIdentity();
507
+ // Reuse the caller's identity when provided — computing it shells out to `git` several times,
508
+ // and run() has already done so for its log line. Recomputing here would double those git
509
+ // spawns (and, on Windows, the console-window flashes) on every `rech open`.
510
+ const identity = precomputedIdentity ?? await getClientIdentity();
500
511
  const effectiveProfile = resolveEffectiveProfile(profileDirectory);
501
512
  if (effectiveProfile) identity.profile = effectiveProfile;
502
513
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
@@ -545,7 +556,7 @@ async function run(url: string, args: string[]) {
545
556
  );
546
557
 
547
558
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
548
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
559
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
549
560
 
550
561
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
551
562
  if (existingSession && isOpenWithUrl) {
@@ -658,6 +669,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
658
669
  const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
659
670
  stdout: "inherit",
660
671
  stderr: "inherit",
672
+ windowsHide: true, // no console-window flash for the bunx/pm2 child on Windows
661
673
  ...(env ? { env: { ...process.env, ...env } } : {}),
662
674
  });
663
675
  await proc.exited;
@@ -667,7 +679,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
667
679
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
668
680
  // Both render the process name verbatim, so callers can substring-match it.
669
681
  async function pmList(): Promise<string> {
670
- const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore" });
682
+ const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
671
683
  return await new Response(proc.stdout).text();
672
684
  }
673
685
 
@@ -983,7 +995,7 @@ async function provisionExtensionToken(opts: {
983
995
  if (!opts.headed) args.push("--headless=new");
984
996
  if (process.platform === "linux") args.push("--no-sandbox");
985
997
  args.push("about:blank");
986
- const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
998
+ const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
987
999
  let cdp: CDPClient | null = null;
988
1000
  try {
989
1001
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
@@ -1198,13 +1210,34 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1198
1210
  );
1199
1211
  if (opts.profile !== undefined) {
1200
1212
  const num = parseInt(opts.profile);
1201
- if (!isNaN(num) && String(num) === opts.profile.trim()) return available[num - 1] ?? null;
1213
+ if (!isNaN(num) && String(num) === opts.profile.trim()) {
1214
+ // A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
1215
+ // "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
1216
+ // *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
1217
+ // and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
1218
+ // selection either way. Email is the unambiguous selector — steer toward it.
1219
+ const sel = available[num - 1] ?? null;
1220
+ const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
1221
+ if (dirNamed && dirNamed[0] !== sel?.[0]) {
1222
+ const hint = dirNamed[1].user_name || `"Profile ${num}"`;
1223
+ console.error(
1224
+ ` [warn] --profile ${num} = menu index ${num} → ` +
1225
+ `${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
1226
+ `NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
1227
+ `For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
1228
+ );
1229
+ }
1230
+ if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
1231
+ return sel;
1232
+ }
1202
1233
  const needle = opts.profile.toLowerCase();
1203
- return available.find(([dir, info]) =>
1234
+ const match = available.find(([dir, info]) =>
1204
1235
  dir.toLowerCase() === needle
1205
1236
  || (info.name ?? "").toLowerCase() === needle
1206
1237
  || (info.user_name ?? "").toLowerCase().includes(needle)
1207
1238
  ) ?? null;
1239
+ if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
1240
+ return match;
1208
1241
  }
1209
1242
  if (available.length === 1) {
1210
1243
  console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
@@ -1414,14 +1447,17 @@ async function status(): Promise<void> {
1414
1447
  const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
1415
1448
  // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
1416
1449
  // POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
1417
- const bind = ping
1450
+ const pingBody = ping
1418
1451
  ? await fetch(`${protocol}://${host}:${port}/ping`, {
1419
1452
  headers: { Authorization: `Bearer ${parsed.key}` },
1420
1453
  signal: AbortSignal.timeout(2000),
1421
- }).then(r => (r.ok ? r.json() : null)).then((b: { bind?: string } | null) => b?.bind).catch(() => undefined)
1422
- : undefined;
1454
+ }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
1455
+ : null;
1456
+ const bind = pingBody?.bind;
1423
1457
  const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
1424
1458
  console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
1459
+ if (pingBody?.degraded)
1460
+ 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
1461
  const pmOut = await pmList();
1426
1462
  const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
1427
1463
  console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
@@ -1448,7 +1484,12 @@ function printHelp(): void {
1448
1484
  Usage:
1449
1485
  rech setup [--profile <num|email>] [--token <tok>]
1450
1486
  First-time setup: daemon + Chrome extension + config
1451
- --profile selects the Chrome profile non-interactively
1487
+ --profile selects the Chrome profile non-interactively.
1488
+ A bare number is the 1-based MENU INDEX (position in the
1489
+ listed order), NOT the Chrome directory "Profile N". For
1490
+ scripts prefer the email (e.g. --profile you@gmail.com) —
1491
+ it is the only unambiguous selector; an exact directory
1492
+ name ("Profile 1") also matches.
1452
1493
  --token (or RECH_TOKEN) supplies the auth token for
1453
1494
  non-TTY/agent runs, skipping the interactive paste
1454
1495
  rech provision-profile <name> --experimental [--headed]
package/rech.ts CHANGED
@@ -104,7 +104,7 @@ function openInDefaultApp(target: string): void {
104
104
  const cmd = process.platform === "darwin" ? ["open", target]
105
105
  : process.platform === "win32" ? ["cmd", "/c", "start", "", target]
106
106
  : ["xdg-open", target];
107
- try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
107
+ try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true }); } catch {}
108
108
  }
109
109
 
110
110
  // Best-effort path to the Chrome executable for the current platform (used to open a
@@ -137,7 +137,7 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
137
137
  try {
138
138
  Bun.spawn(
139
139
  [chromeBin, `--profile-directory=${profileDir}`, target],
140
- { stdout: "ignore", stderr: "ignore", detached: true },
140
+ { stdout: "ignore", stderr: "ignore", detached: true, windowsHide: true },
141
141
  );
142
142
  return true;
143
143
  } catch {
@@ -200,7 +200,8 @@ function realpathSafe(p: string): string {
200
200
 
201
201
  async function gitOutput(args: string[], cwd: string): Promise<string | null> {
202
202
  try {
203
- const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
203
+ // windowsHide: don't flash a console window on Windows (git.exe is a console app)
204
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore", windowsHide: true });
204
205
  const out = (await new Response(proc.stdout).text()).trim();
205
206
  await proc.exited;
206
207
  return out || null;
@@ -478,12 +479,18 @@ async function listProfiles(): Promise<void> {
478
479
  }
479
480
  }
480
481
 
481
- const rows = Object.entries(cache).map(([dir, info]) => [
482
- dir,
483
- info.user_name || "",
484
- info.name || "",
485
- dir === currentDir ? " current" : "",
486
- ]);
482
+ // Header clarifies each column; email/nickname sit beside the profile dir so a bare
483
+ // "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
484
+ // from Local State — the gaia real name is deliberately never surfaced.
485
+ const rows = [
486
+ ["PROFILE", "EMAIL", "NICKNAME", ""],
487
+ ...Object.entries(cache).map(([dir, info]) => [
488
+ dir,
489
+ info.user_name || "",
490
+ info.name || "",
491
+ dir === currentDir ? "← current" : "",
492
+ ]),
493
+ ];
487
494
  const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
488
495
  for (const row of rows) {
489
496
  console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
@@ -494,9 +501,13 @@ async function callServe(
494
501
  url: string,
495
502
  args: string[],
496
503
  overrideEnv?: Record<string, string>,
504
+ precomputedIdentity?: { key: string; label: string; profile?: string },
497
505
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
498
506
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
499
- const identity = await getClientIdentity();
507
+ // Reuse the caller's identity when provided — computing it shells out to `git` several times,
508
+ // and run() has already done so for its log line. Recomputing here would double those git
509
+ // spawns (and, on Windows, the console-window flashes) on every `rech open`.
510
+ const identity = precomputedIdentity ?? await getClientIdentity();
500
511
  const effectiveProfile = resolveEffectiveProfile(profileDirectory);
501
512
  if (effectiveProfile) identity.profile = effectiveProfile;
502
513
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
@@ -545,7 +556,7 @@ async function run(url: string, args: string[]) {
545
556
  );
546
557
 
547
558
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
548
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
559
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
549
560
 
550
561
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
551
562
  if (existingSession && isOpenWithUrl) {
@@ -658,6 +669,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
658
669
  const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
659
670
  stdout: "inherit",
660
671
  stderr: "inherit",
672
+ windowsHide: true, // no console-window flash for the bunx/pm2 child on Windows
661
673
  ...(env ? { env: { ...process.env, ...env } } : {}),
662
674
  });
663
675
  await proc.exited;
@@ -667,7 +679,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
667
679
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
668
680
  // Both render the process name verbatim, so callers can substring-match it.
669
681
  async function pmList(): Promise<string> {
670
- const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore" });
682
+ const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
671
683
  return await new Response(proc.stdout).text();
672
684
  }
673
685
 
@@ -983,7 +995,7 @@ async function provisionExtensionToken(opts: {
983
995
  if (!opts.headed) args.push("--headless=new");
984
996
  if (process.platform === "linux") args.push("--no-sandbox");
985
997
  args.push("about:blank");
986
- const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
998
+ const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
987
999
  let cdp: CDPClient | null = null;
988
1000
  try {
989
1001
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
@@ -1198,13 +1210,34 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1198
1210
  );
1199
1211
  if (opts.profile !== undefined) {
1200
1212
  const num = parseInt(opts.profile);
1201
- if (!isNaN(num) && String(num) === opts.profile.trim()) return available[num - 1] ?? null;
1213
+ if (!isNaN(num) && String(num) === opts.profile.trim()) {
1214
+ // A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
1215
+ // "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
1216
+ // *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
1217
+ // and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
1218
+ // selection either way. Email is the unambiguous selector — steer toward it.
1219
+ const sel = available[num - 1] ?? null;
1220
+ const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
1221
+ if (dirNamed && dirNamed[0] !== sel?.[0]) {
1222
+ const hint = dirNamed[1].user_name || `"Profile ${num}"`;
1223
+ console.error(
1224
+ ` [warn] --profile ${num} = menu index ${num} → ` +
1225
+ `${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
1226
+ `NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
1227
+ `For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
1228
+ );
1229
+ }
1230
+ if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
1231
+ return sel;
1232
+ }
1202
1233
  const needle = opts.profile.toLowerCase();
1203
- return available.find(([dir, info]) =>
1234
+ const match = available.find(([dir, info]) =>
1204
1235
  dir.toLowerCase() === needle
1205
1236
  || (info.name ?? "").toLowerCase() === needle
1206
1237
  || (info.user_name ?? "").toLowerCase().includes(needle)
1207
1238
  ) ?? null;
1239
+ if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
1240
+ return match;
1208
1241
  }
1209
1242
  if (available.length === 1) {
1210
1243
  console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
@@ -1414,14 +1447,17 @@ async function status(): Promise<void> {
1414
1447
  const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
1415
1448
  // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
1416
1449
  // POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
1417
- const bind = ping
1450
+ const pingBody = ping
1418
1451
  ? await fetch(`${protocol}://${host}:${port}/ping`, {
1419
1452
  headers: { Authorization: `Bearer ${parsed.key}` },
1420
1453
  signal: AbortSignal.timeout(2000),
1421
- }).then(r => (r.ok ? r.json() : null)).then((b: { bind?: string } | null) => b?.bind).catch(() => undefined)
1422
- : undefined;
1454
+ }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
1455
+ : null;
1456
+ const bind = pingBody?.bind;
1423
1457
  const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
1424
1458
  console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
1459
+ if (pingBody?.degraded)
1460
+ 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
1461
  const pmOut = await pmList();
1426
1462
  const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
1427
1463
  console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
@@ -1448,7 +1484,12 @@ function printHelp(): void {
1448
1484
  Usage:
1449
1485
  rech setup [--profile <num|email>] [--token <tok>]
1450
1486
  First-time setup: daemon + Chrome extension + config
1451
- --profile selects the Chrome profile non-interactively
1487
+ --profile selects the Chrome profile non-interactively.
1488
+ A bare number is the 1-based MENU INDEX (position in the
1489
+ listed order), NOT the Chrome directory "Profile N". For
1490
+ scripts prefer the email (e.g. --profile you@gmail.com) —
1491
+ it is the only unambiguous selector; an exact directory
1492
+ name ("Profile 1") also matches.
1452
1493
  --token (or RECH_TOKEN) supplies the auth token for
1453
1494
  non-TTY/agent runs, skipping the interactive paste
1454
1495
  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)`);