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
|
-
|
|
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]
|
|
566
|
+
"[bridge] token refresh failed; reusing last token (will retry refresh on reconnect)\n",
|
|
550
567
|
);
|
|
551
|
-
this.options.authToken
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2368
|
-
|
|
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
|
-
|
|
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;
|