linkshell-cli 0.5.4 → 0.5.5

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.
@@ -21,7 +21,7 @@ import { ScreenShare } from "./screen-share.js";
21
21
  import { getLanIp } from "../utils/lan-ip.js";
22
22
  import { startKeepAwake, type KeepAwakeHandle } from "../utils/keep-awake.js";
23
23
  import { loadOrCreateMachineIdentity, type MachineIdentity } from "../machine-id.js";
24
- import { getValidToken } from "../auth.js";
24
+ import { getValidToken, refreshAccessToken } from "../auth.js";
25
25
  import { AgentSessionProxy } from "./acp/agent-session.js";
26
26
  import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
27
27
  import { detectAvailableProviders, type AgentProvider } from "./acp/provider-resolver.js";
@@ -57,9 +57,17 @@ export interface BridgeSessionOptions {
57
57
  }
58
58
 
59
59
  const HEARTBEAT_INTERVAL = 15_000;
60
+ // Transport-layer WS keepalive: ping the gateway periodically; if a pong
61
+ // doesn't arrive before the next ping, the socket is half-open (network
62
+ // silently dropped, no FIN) — terminate it so the reconnect loop kicks in.
63
+ const WS_PING_INTERVAL = 20_000;
60
64
  const RECONNECT_BASE_DELAY = 1_000;
61
65
  const RECONNECT_MAX_DELAY = 30_000;
62
- const RECONNECT_MAX_ATTEMPTS = 20;
66
+ // Cap the exponent so the backoff levels off at RECONNECT_MAX_DELAY instead of
67
+ // overflowing. There is NO attempt ceiling — a connected host never gives up
68
+ // (only `stop()`/all-PTYs-exited ends it), so it survives gateway restarts and
69
+ // arbitrarily long network outages, reconnecting whenever connectivity returns.
70
+ const RECONNECT_MAX_EXPONENT = 5;
63
71
  const DEFAULT_TERMINAL_ID = "default";
64
72
  const HOOK_BODY_LIMIT = 256 * 1024;
65
73
  const SCROLLBACK_LINES = 500;
@@ -349,6 +357,10 @@ export class BridgeSession {
349
357
  private socket: WebSocket | undefined;
350
358
  private terminals = new Map<string, TerminalInstance>();
351
359
  private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
360
+ private wsPingTimer: ReturnType<typeof setInterval> | undefined;
361
+ // True between sending a WS ping and receiving its pong. If still true at the
362
+ // next ping tick, the socket is half-open and gets terminated.
363
+ private awaitingPong = false;
352
364
  private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
353
365
  private reconnectAttempts = 0;
354
366
  private reconnecting = false;
@@ -545,17 +557,40 @@ export class BridgeSession {
545
557
  this.options.authToken = token;
546
558
  return token;
547
559
  }
560
+ // Refresh failed (e.g. offline / transient Supabase error). Do NOT clear
561
+ // the token — keep using the last one we had. It may be expired and the
562
+ // gateway may reject it, but the close handler force-refreshes on the next
563
+ // attempt, and clearing it would permanently downgrade us to no-auth and
564
+ // lock a pro host out of its own sessions once connectivity returns.
548
565
  process.stderr.write(
549
- "[bridge] login token expired and refresh failed; run `linkshell login` if the gateway rejects the connection\n",
566
+ "[bridge] token refresh failed; reusing last token (will retry refresh on reconnect)\n",
550
567
  );
551
- this.options.authToken = undefined;
552
- return undefined;
568
+ return this.options.authToken;
553
569
  } catch (error) {
554
570
  this.log(`failed to refresh login token: ${error instanceof Error ? error.message : String(error)}`);
555
571
  return this.options.authToken;
556
572
  }
557
573
  }
558
574
 
575
+ // After an auth-class WS close, force a token refresh before reconnecting so
576
+ // we don't spin on 401/4001/4003. A gateway restart surfaces as 1006 right
577
+ // after a fresh connect, so we refresh on that too (cheap, debounced by the
578
+ // reconnect backoff). No-op for clean/normal closes.
579
+ private async maybeRefreshTokenForClose(code: number): Promise<void> {
580
+ if (!this.options.authToken) return;
581
+ const authClass = code === 4001 || code === 4003 || code === 1006 || code === 401;
582
+ if (!authClass) return;
583
+ try {
584
+ const refreshed = await refreshAccessToken();
585
+ if (refreshed?.accessToken) {
586
+ this.options.authToken = refreshed.accessToken;
587
+ this.log("refreshed auth token after auth-class close");
588
+ }
589
+ } catch {
590
+ // Offline — keep the old token; the reconnect loop will retry later.
591
+ }
592
+ }
593
+
559
594
  private async connectGateway(): Promise<void> {
560
595
  if (this.stopped) {
561
596
  return;
@@ -610,6 +645,12 @@ export class BridgeSession {
610
645
  }),
611
646
  );
612
647
  this.startHeartbeat();
648
+ this.startWsPing();
649
+ });
650
+
651
+ // Gateway answered our WS ping — connection is alive.
652
+ this.socket.on("pong", () => {
653
+ this.awaitingPong = false;
613
654
  });
614
655
 
615
656
  this.socket.on("message", (data) => {
@@ -630,18 +671,32 @@ export class BridgeSession {
630
671
 
631
672
  this.socket.on("close", (code, reasonBuffer) => {
632
673
  this.stopHeartbeat();
674
+ this.stopWsPing();
633
675
  this.socket = undefined;
634
676
  const reason = reasonBuffer.toString();
635
- process.stderr.write(
636
- `[bridge] gateway connection closed (code=${code}${reason ? `, reason=${reason}` : ""})\n`,
637
- );
677
+ // Quiet during an ongoing outage: log the first close (we were connected,
678
+ // attempts==0) and every 10th, so multi-hour outages don't flood the log.
679
+ if (this.reconnectAttempts === 0 || this.reconnectAttempts % 10 === 0) {
680
+ process.stderr.write(
681
+ `[bridge] gateway connection closed (code=${code}${reason ? `, reason=${reason}` : ""})\n`,
682
+ );
683
+ }
638
684
  if (!this.exited) {
639
- this.scheduleReconnect();
685
+ // Auth-class closes (token expired / gateway restarted) get a forced
686
+ // token refresh before the next attempt, so we don't loop on 401/4xxx.
687
+ this.maybeRefreshTokenForClose(code).finally(() => {
688
+ this.scheduleReconnect();
689
+ });
640
690
  }
641
691
  });
642
692
 
643
693
  this.socket.on("error", (error) => {
644
- process.stderr.write(`[bridge] gateway error: ${error.message}\n`);
694
+ // During an outage every retry emits a connection error; the `close`
695
+ // handler already logs (quietly) and drives reconnect. Only surface the
696
+ // first error of an outage to avoid flooding the daemon log.
697
+ if (this.reconnectAttempts <= 1) {
698
+ process.stderr.write(`[bridge] gateway error: ${error.message || "connection failed"}\n`);
699
+ }
645
700
  });
646
701
  }
647
702
 
@@ -2299,6 +2354,45 @@ export class BridgeSession {
2299
2354
  }
2300
2355
  }
2301
2356
 
2357
+ // Transport-layer keepalive. WS ping/pong detects a half-open socket (network
2358
+ // dropped without a TCP FIN, e.g. laptop sleep / Wi-Fi loss) that would
2359
+ // otherwise hang silently. If the previous ping's pong never arrived, the
2360
+ // socket is dead → terminate it, which fires `close` → scheduleReconnect.
2361
+ private startWsPing(): void {
2362
+ this.stopWsPing();
2363
+ this.awaitingPong = false;
2364
+ this.wsPingTimer = setInterval(() => {
2365
+ const sock = this.socket;
2366
+ if (!sock || sock.readyState !== WebSocket.OPEN) return;
2367
+ if (this.awaitingPong) {
2368
+ // No pong since the last ping → half-open. Forcibly close so we reconnect.
2369
+ process.stderr.write("[bridge] no pong from gateway; terminating dead connection\n");
2370
+ this.awaitingPong = false;
2371
+ try {
2372
+ sock.terminate();
2373
+ } catch {
2374
+ // terminate may throw if already closing; close handler still runs.
2375
+ }
2376
+ return;
2377
+ }
2378
+ this.awaitingPong = true;
2379
+ try {
2380
+ sock.ping();
2381
+ } catch {
2382
+ // ping failed → let the next tick terminate, or close already fired.
2383
+ }
2384
+ }, WS_PING_INTERVAL);
2385
+ if (typeof this.wsPingTimer.unref === "function") this.wsPingTimer.unref();
2386
+ }
2387
+
2388
+ private stopWsPing(): void {
2389
+ if (this.wsPingTimer) {
2390
+ clearInterval(this.wsPingTimer);
2391
+ this.wsPingTimer = undefined;
2392
+ }
2393
+ this.awaitingPong = false;
2394
+ }
2395
+
2302
2396
  private startScreenCapture(fps: number, quality: number, scale: number): void {
2303
2397
  if (!this.options.screen) {
2304
2398
  this.log("screen sharing not enabled (use --screen)");
@@ -2362,25 +2456,24 @@ export class BridgeSession {
2362
2456
  }
2363
2457
 
2364
2458
  private scheduleReconnect(): void {
2365
- if (this.reconnecting) return;
2459
+ if (this.reconnecting || this.stopped || this.exited) return;
2366
2460
 
2367
- // In daemon mode, never give up — reset attempts after hitting max
2368
- if (this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
2461
+ this.reconnecting = true;
2462
+ // Exponential backoff capped at RECONNECT_MAX_DELAY, with full jitter so a
2463
+ // fleet of hosts doesn't stampede a restarting gateway. The attempt counter
2464
+ // only ever grows toward the cap — it is NEVER reset to "give up"; the host
2465
+ // keeps trying forever until stop()/exit.
2466
+ const exponent = Math.min(this.reconnectAttempts, RECONNECT_MAX_EXPONENT);
2467
+ const base = Math.min(RECONNECT_BASE_DELAY * 2 ** exponent, RECONNECT_MAX_DELAY);
2468
+ const delay = Math.round(base * (0.5 + Math.random() * 0.5));
2469
+ this.reconnectAttempts++;
2470
+ // Quiet logging: only the first retry of an outage and every 10th attempt,
2471
+ // so a multi-hour offline window doesn't flood the daemon log.
2472
+ if (this.reconnectAttempts === 1 || this.reconnectAttempts % 10 === 0) {
2369
2473
  process.stderr.write(
2370
- "[bridge] max reconnect attempts reached, resetting counter and continuing...\n",
2474
+ `[bridge] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})\n`,
2371
2475
  );
2372
- this.reconnectAttempts = 0;
2373
2476
  }
2374
-
2375
- this.reconnecting = true;
2376
- const delay = Math.min(
2377
- RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts,
2378
- RECONNECT_MAX_DELAY,
2379
- );
2380
- this.reconnectAttempts++;
2381
- process.stderr.write(
2382
- `[bridge] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})\n`,
2383
- );
2384
2477
  this.reconnectTimer = setTimeout(() => {
2385
2478
  this.reconnectTimer = undefined;
2386
2479
  if (this.stopped || this.exited) {
@@ -2399,6 +2492,7 @@ export class BridgeSession {
2399
2492
  this.stopped = true;
2400
2493
  this.exited = true;
2401
2494
  this.stopHeartbeat();
2495
+ this.stopWsPing();
2402
2496
  this.stopScreenCapture();
2403
2497
  this.agentSession?.stop();
2404
2498
  this.agentSession = undefined;