linkshell-cli 0.5.2 → 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.
- package/dist/cli/src/index.js +22 -0
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/acp/acp-client.js +41 -19
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +8 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +164 -16
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +1 -0
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +18 -0
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +37 -0
- package/dist/cli/src/runtime/bridge-session.js +333 -28
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +3810 -2286
- package/dist/shared-protocol/src/index.js +81 -13
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +28 -0
- package/src/runtime/acp/acp-client.ts +43 -19
- package/src/runtime/acp/agent-workspace.ts +173 -16
- package/src/runtime/acp/claude-stream-json-client.ts +23 -0
- package/src/runtime/bridge-session.ts +348 -39
|
@@ -4,7 +4,7 @@ import WebSocket from "ws";
|
|
|
4
4
|
import { hostname, platform, homedir } from "node:os";
|
|
5
5
|
import { writeFileSync, readFileSync, readdirSync, statSync, unlinkSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { join, basename, resolve } from "node:path";
|
|
7
|
+
import { join, basename, resolve, relative, isAbsolute } from "node:path";
|
|
8
8
|
import { agentV2MessageRoute, createEnvelope, parseEnvelope, parseTypedPayload, serializeEnvelope, PROTOCOL_VERSION, } from "@linkshell/protocol";
|
|
9
9
|
import { ScrollbackBuffer } from "./scrollback.js";
|
|
10
10
|
import { ScreenFallback } from "./screen-fallback.js";
|
|
@@ -12,16 +12,34 @@ import { ScreenShare } from "./screen-share.js";
|
|
|
12
12
|
import { getLanIp } from "../utils/lan-ip.js";
|
|
13
13
|
import { startKeepAwake } from "../utils/keep-awake.js";
|
|
14
14
|
import { loadOrCreateMachineIdentity } from "../machine-id.js";
|
|
15
|
-
import { getValidToken } from "../auth.js";
|
|
15
|
+
import { getValidToken, refreshAccessToken } from "../auth.js";
|
|
16
16
|
import { AgentSessionProxy } from "./acp/agent-session.js";
|
|
17
17
|
import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
|
|
18
18
|
import { detectAvailableProviders } from "./acp/provider-resolver.js";
|
|
19
19
|
const HEARTBEAT_INTERVAL = 15_000;
|
|
20
|
+
// Transport-layer WS keepalive: ping the gateway periodically; if a pong
|
|
21
|
+
// doesn't arrive before the next ping, the socket is half-open (network
|
|
22
|
+
// silently dropped, no FIN) — terminate it so the reconnect loop kicks in.
|
|
23
|
+
const WS_PING_INTERVAL = 20_000;
|
|
20
24
|
const RECONNECT_BASE_DELAY = 1_000;
|
|
21
25
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
22
|
-
|
|
26
|
+
// Cap the exponent so the backoff levels off at RECONNECT_MAX_DELAY instead of
|
|
27
|
+
// overflowing. There is NO attempt ceiling — a connected host never gives up
|
|
28
|
+
// (only `stop()`/all-PTYs-exited ends it), so it survives gateway restarts and
|
|
29
|
+
// arbitrarily long network outages, reconnecting whenever connectivity returns.
|
|
30
|
+
const RECONNECT_MAX_EXPONENT = 5;
|
|
23
31
|
const DEFAULT_TERMINAL_ID = "default";
|
|
24
32
|
const HOOK_BODY_LIMIT = 256 * 1024;
|
|
33
|
+
const SCROLLBACK_LINES = 500;
|
|
34
|
+
// Upper bound on concurrent live terminals — forceNew removed the cwd-dedup
|
|
35
|
+
// ceiling, so bound growth to avoid PTY/fd exhaustion from a runaway client.
|
|
36
|
+
const MAX_TERMINALS = 12;
|
|
37
|
+
// How long to keep an exited terminal (with its scrollback) in memory so the
|
|
38
|
+
// client can replay its final output before it is reaped.
|
|
39
|
+
const EXITED_TERMINAL_GRACE_MS = 30_000;
|
|
40
|
+
// Default ports the bridge will proxy tunnel requests to when no explicit
|
|
41
|
+
// allowlist is configured. Covers the common dev servers the app tunnels to.
|
|
42
|
+
const DEFAULT_TUNNEL_PORTS = [3000, 3001, 4321, 5173, 5174, 8080, 8000, 8081];
|
|
25
43
|
const PERMISSION_REQUEST_TIMEOUT_MS = Number(process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000);
|
|
26
44
|
const LINKSHELL_PERMISSION_GUARD_MARKER = "LINKSHELL_PERMISSION_GUARD";
|
|
27
45
|
function isLinkShellHookEntry(entry, marker) {
|
|
@@ -231,10 +249,15 @@ export class BridgeSession {
|
|
|
231
249
|
socket;
|
|
232
250
|
terminals = new Map();
|
|
233
251
|
heartbeatTimer;
|
|
252
|
+
wsPingTimer;
|
|
253
|
+
// True between sending a WS ping and receiving its pong. If still true at the
|
|
254
|
+
// next ping tick, the socket is half-open and gets terminated.
|
|
255
|
+
awaitingPong = false;
|
|
234
256
|
reconnectTimer;
|
|
235
257
|
reconnectAttempts = 0;
|
|
236
258
|
reconnecting = false;
|
|
237
259
|
sessionId = "";
|
|
260
|
+
hostToken;
|
|
238
261
|
exited = false;
|
|
239
262
|
stopped = false;
|
|
240
263
|
permissionStacks = new Map();
|
|
@@ -248,9 +271,48 @@ export class BridgeSession {
|
|
|
248
271
|
agentSession;
|
|
249
272
|
agentWorkspace;
|
|
250
273
|
machineIdentity;
|
|
274
|
+
// Confinement root for client-driven filesystem ops; resolved once at construction.
|
|
275
|
+
fileRoot;
|
|
276
|
+
// Ports the tunnel proxy may connect to on 127.0.0.1.
|
|
277
|
+
allowedTunnelPorts;
|
|
278
|
+
// Pending deletions of exited terminals (terminalId → timer) so we can clear on stop().
|
|
279
|
+
exitedTerminalTimers = new Map();
|
|
280
|
+
// Invoked when all terminals have exited naturally — closes embedded gateway,
|
|
281
|
+
// removes daemon PID file, etc. Set by the CLI entry point.
|
|
282
|
+
onAllTerminalsExited;
|
|
251
283
|
constructor(options) {
|
|
252
284
|
this.options = options;
|
|
253
285
|
this.sessionId = options.sessionId ?? "";
|
|
286
|
+
// Confine client-driven file ops to the user's home by default (single-user
|
|
287
|
+
// self-hosted model: the owner browses their own projects under ~). This
|
|
288
|
+
// still blocks /etc, /usr, other users' dirs. Override via --file-root.
|
|
289
|
+
this.fileRoot = resolve(options.fileRoot ?? homedir());
|
|
290
|
+
this.allowedTunnelPorts = new Set(options.allowedTunnelPorts && options.allowedTunnelPorts.length > 0
|
|
291
|
+
? options.allowedTunnelPorts
|
|
292
|
+
: DEFAULT_TUNNEL_PORTS);
|
|
293
|
+
}
|
|
294
|
+
/** Register a callback invoked once all terminals exit naturally (process self-cleanup). */
|
|
295
|
+
setOnAllTerminalsExited(handler) {
|
|
296
|
+
this.onAllTerminalsExited = handler;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Resolve a client-supplied path inside the confinement root.
|
|
300
|
+
* Returns the absolute resolved path, or null if it escapes the root.
|
|
301
|
+
*/
|
|
302
|
+
resolveConfinedPath(clientPath) {
|
|
303
|
+
const expanded = clientPath.startsWith("~")
|
|
304
|
+
? clientPath.replace(/^~/, homedir())
|
|
305
|
+
: clientPath;
|
|
306
|
+
// Resolve relative paths against the confinement root, not process.cwd().
|
|
307
|
+
const target = isAbsolute(expanded)
|
|
308
|
+
? resolve(expanded)
|
|
309
|
+
: resolve(this.fileRoot, expanded);
|
|
310
|
+
const rel = relative(this.fileRoot, target);
|
|
311
|
+
if (rel === "")
|
|
312
|
+
return target; // the root itself
|
|
313
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
314
|
+
return null;
|
|
315
|
+
return target;
|
|
254
316
|
}
|
|
255
317
|
log(msg) {
|
|
256
318
|
if (this.options.verbose) {
|
|
@@ -264,6 +326,9 @@ export class BridgeSession {
|
|
|
264
326
|
async start() {
|
|
265
327
|
this.log(`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`);
|
|
266
328
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
329
|
+
// Sweep stale LinkShell hook entries (dead curl ports) left by a prior crash
|
|
330
|
+
// before we write fresh ones for this session.
|
|
331
|
+
this.sweepStaleHookConfigs();
|
|
267
332
|
if (!this.sessionId) {
|
|
268
333
|
await this.createPairing();
|
|
269
334
|
}
|
|
@@ -311,6 +376,10 @@ export class BridgeSession {
|
|
|
311
376
|
}
|
|
312
377
|
const body = (await res.json());
|
|
313
378
|
this.sessionId = body.sessionId;
|
|
379
|
+
// Secret token proving we are the legitimate host for this session; sent on
|
|
380
|
+
// every host WS connect so a third party who learns the sessionId can't
|
|
381
|
+
// hijack the host role.
|
|
382
|
+
this.hostToken = body.hostToken;
|
|
314
383
|
const pairingGateway = resolvePairingGateway(this.options.gatewayHttpUrl, this.options.pairingGateway);
|
|
315
384
|
const deepLink = pairingGateway
|
|
316
385
|
? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
|
|
@@ -352,15 +421,40 @@ export class BridgeSession {
|
|
|
352
421
|
this.options.authToken = token;
|
|
353
422
|
return token;
|
|
354
423
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
424
|
+
// Refresh failed (e.g. offline / transient Supabase error). Do NOT clear
|
|
425
|
+
// the token — keep using the last one we had. It may be expired and the
|
|
426
|
+
// gateway may reject it, but the close handler force-refreshes on the next
|
|
427
|
+
// attempt, and clearing it would permanently downgrade us to no-auth and
|
|
428
|
+
// lock a pro host out of its own sessions once connectivity returns.
|
|
429
|
+
process.stderr.write("[bridge] token refresh failed; reusing last token (will retry refresh on reconnect)\n");
|
|
430
|
+
return this.options.authToken;
|
|
358
431
|
}
|
|
359
432
|
catch (error) {
|
|
360
433
|
this.log(`failed to refresh login token: ${error instanceof Error ? error.message : String(error)}`);
|
|
361
434
|
return this.options.authToken;
|
|
362
435
|
}
|
|
363
436
|
}
|
|
437
|
+
// After an auth-class WS close, force a token refresh before reconnecting so
|
|
438
|
+
// we don't spin on 401/4001/4003. A gateway restart surfaces as 1006 right
|
|
439
|
+
// after a fresh connect, so we refresh on that too (cheap, debounced by the
|
|
440
|
+
// reconnect backoff). No-op for clean/normal closes.
|
|
441
|
+
async maybeRefreshTokenForClose(code) {
|
|
442
|
+
if (!this.options.authToken)
|
|
443
|
+
return;
|
|
444
|
+
const authClass = code === 4001 || code === 4003 || code === 1006 || code === 401;
|
|
445
|
+
if (!authClass)
|
|
446
|
+
return;
|
|
447
|
+
try {
|
|
448
|
+
const refreshed = await refreshAccessToken();
|
|
449
|
+
if (refreshed?.accessToken) {
|
|
450
|
+
this.options.authToken = refreshed.accessToken;
|
|
451
|
+
this.log("refreshed auth token after auth-class close");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
// Offline — keep the old token; the reconnect loop will retry later.
|
|
456
|
+
}
|
|
457
|
+
}
|
|
364
458
|
async connectGateway() {
|
|
365
459
|
if (this.stopped) {
|
|
366
460
|
return;
|
|
@@ -369,10 +463,23 @@ export class BridgeSession {
|
|
|
369
463
|
url.searchParams.set("sessionId", this.sessionId);
|
|
370
464
|
url.searchParams.set("role", "host");
|
|
371
465
|
const authToken = await this.resolveAuthToken();
|
|
466
|
+
const wsOptions = {};
|
|
467
|
+
const headers = {};
|
|
372
468
|
if (authToken) {
|
|
469
|
+
// Prefer the Authorization header so the token doesn't leak into URLs/logs.
|
|
470
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
471
|
+
// TODO: remove the query param once all gateways read the Authorization
|
|
472
|
+
// header. Kept for backward compatibility with older gateways that only
|
|
473
|
+
// read the `auth_token` query parameter.
|
|
373
474
|
url.searchParams.set("auth_token", authToken);
|
|
374
475
|
}
|
|
375
|
-
this.
|
|
476
|
+
if (this.hostToken) {
|
|
477
|
+
headers["x-linkshell-host-token"] = this.hostToken;
|
|
478
|
+
}
|
|
479
|
+
if (Object.keys(headers).length > 0) {
|
|
480
|
+
wsOptions.headers = headers;
|
|
481
|
+
}
|
|
482
|
+
this.socket = new WebSocket(url, wsOptions);
|
|
376
483
|
this.socket.on("open", () => {
|
|
377
484
|
process.stderr.write(this.reconnectAttempts > 0
|
|
378
485
|
? "[bridge] gateway reconnected\n"
|
|
@@ -395,6 +502,11 @@ export class BridgeSession {
|
|
|
395
502
|
},
|
|
396
503
|
}));
|
|
397
504
|
this.startHeartbeat();
|
|
505
|
+
this.startWsPing();
|
|
506
|
+
});
|
|
507
|
+
// Gateway answered our WS ping — connection is alive.
|
|
508
|
+
this.socket.on("pong", () => {
|
|
509
|
+
this.awaitingPong = false;
|
|
398
510
|
});
|
|
399
511
|
this.socket.on("message", (data) => {
|
|
400
512
|
let envelope;
|
|
@@ -412,15 +524,29 @@ export class BridgeSession {
|
|
|
412
524
|
});
|
|
413
525
|
this.socket.on("close", (code, reasonBuffer) => {
|
|
414
526
|
this.stopHeartbeat();
|
|
527
|
+
this.stopWsPing();
|
|
415
528
|
this.socket = undefined;
|
|
416
529
|
const reason = reasonBuffer.toString();
|
|
417
|
-
|
|
530
|
+
// Quiet during an ongoing outage: log the first close (we were connected,
|
|
531
|
+
// attempts==0) and every 10th, so multi-hour outages don't flood the log.
|
|
532
|
+
if (this.reconnectAttempts === 0 || this.reconnectAttempts % 10 === 0) {
|
|
533
|
+
process.stderr.write(`[bridge] gateway connection closed (code=${code}${reason ? `, reason=${reason}` : ""})\n`);
|
|
534
|
+
}
|
|
418
535
|
if (!this.exited) {
|
|
419
|
-
|
|
536
|
+
// Auth-class closes (token expired / gateway restarted) get a forced
|
|
537
|
+
// token refresh before the next attempt, so we don't loop on 401/4xxx.
|
|
538
|
+
this.maybeRefreshTokenForClose(code).finally(() => {
|
|
539
|
+
this.scheduleReconnect();
|
|
540
|
+
});
|
|
420
541
|
}
|
|
421
542
|
});
|
|
422
543
|
this.socket.on("error", (error) => {
|
|
423
|
-
|
|
544
|
+
// During an outage every retry emits a connection error; the `close`
|
|
545
|
+
// handler already logs (quietly) and drives reconnect. Only surface the
|
|
546
|
+
// first error of an outage to avoid flooding the daemon log.
|
|
547
|
+
if (this.reconnectAttempts <= 1) {
|
|
548
|
+
process.stderr.write(`[bridge] gateway error: ${error.message || "connection failed"}\n`);
|
|
549
|
+
}
|
|
424
550
|
});
|
|
425
551
|
}
|
|
426
552
|
async handleMessage(envelope) {
|
|
@@ -486,8 +612,22 @@ export class BridgeSession {
|
|
|
486
612
|
case "terminal.spawn": {
|
|
487
613
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
488
614
|
const normalizedCwd = resolve(p.cwd);
|
|
489
|
-
//
|
|
490
|
-
|
|
615
|
+
// Cap concurrent terminals — forceNew removed the cwd-dedup ceiling, so
|
|
616
|
+
// bound growth explicitly to avoid PTY/fd exhaustion from a runaway client.
|
|
617
|
+
const liveCount = [...this.terminals.values()].filter((t) => t.status === "running").length;
|
|
618
|
+
if (liveCount >= MAX_TERMINALS) {
|
|
619
|
+
this.send(createEnvelope({
|
|
620
|
+
type: "session.error",
|
|
621
|
+
sessionId: this.sessionId,
|
|
622
|
+
payload: { code: "too_many_terminals", message: `最多同时打开 ${MAX_TERMINALS} 个终端` },
|
|
623
|
+
}));
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
// Dedup by cwd UNLESS the client explicitly asks for a fresh terminal
|
|
627
|
+
// (forceNew lets web open multiple tabs in the same directory).
|
|
628
|
+
const existing = p.forceNew
|
|
629
|
+
? undefined
|
|
630
|
+
: [...this.terminals.values()].find((t) => t.status === "running" && resolve(t.cwd) === normalizedCwd);
|
|
491
631
|
if (existing) {
|
|
492
632
|
this.send(createEnvelope({
|
|
493
633
|
type: "terminal.spawned",
|
|
@@ -530,9 +670,15 @@ export class BridgeSession {
|
|
|
530
670
|
}
|
|
531
671
|
case "terminal.browse": {
|
|
532
672
|
const p = parseTypedPayload("terminal.browse", envelope.payload);
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
673
|
+
const browsePath = this.resolveConfinedPath(p.path);
|
|
674
|
+
if (browsePath === null) {
|
|
675
|
+
this.send(createEnvelope({
|
|
676
|
+
type: "terminal.browse.result",
|
|
677
|
+
sessionId: this.sessionId,
|
|
678
|
+
payload: { path: p.path, entries: [], error: "Path is outside the allowed root", requestId: p.requestId },
|
|
679
|
+
}));
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
536
682
|
try {
|
|
537
683
|
const entries = readdirSync(browsePath, { withFileTypes: true })
|
|
538
684
|
.filter((d) => !d.name.startsWith(".") && (d.isDirectory() || (p.includeFiles && d.isFile())))
|
|
@@ -569,8 +715,22 @@ export class BridgeSession {
|
|
|
569
715
|
}
|
|
570
716
|
case "terminal.file.read": {
|
|
571
717
|
const p = parseTypedPayload("terminal.file.read", envelope.payload);
|
|
572
|
-
const
|
|
573
|
-
|
|
718
|
+
const filePath = this.resolveConfinedPath(p.path);
|
|
719
|
+
if (filePath === null) {
|
|
720
|
+
this.send(createEnvelope({
|
|
721
|
+
type: "terminal.file.read.result",
|
|
722
|
+
sessionId: this.sessionId,
|
|
723
|
+
payload: {
|
|
724
|
+
path: p.path,
|
|
725
|
+
content: "",
|
|
726
|
+
encoding: "utf8",
|
|
727
|
+
truncated: false,
|
|
728
|
+
error: "Path is outside the allowed root",
|
|
729
|
+
requestId: p.requestId,
|
|
730
|
+
},
|
|
731
|
+
}));
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
574
734
|
try {
|
|
575
735
|
const stats = statSync(filePath);
|
|
576
736
|
if (!stats.isFile()) {
|
|
@@ -620,8 +780,15 @@ export class BridgeSession {
|
|
|
620
780
|
}
|
|
621
781
|
case "terminal.mkdir": {
|
|
622
782
|
const p = parseTypedPayload("terminal.mkdir", envelope.payload);
|
|
623
|
-
const
|
|
624
|
-
|
|
783
|
+
const dirPath = this.resolveConfinedPath(p.path);
|
|
784
|
+
if (dirPath === null) {
|
|
785
|
+
this.send(createEnvelope({
|
|
786
|
+
type: "terminal.browse.result",
|
|
787
|
+
sessionId: this.sessionId,
|
|
788
|
+
payload: { path: p.path, entries: [], error: "Path is outside the allowed root" },
|
|
789
|
+
}));
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
625
792
|
try {
|
|
626
793
|
mkdirSync(dirPath, { recursive: true });
|
|
627
794
|
// Browse the parent to refresh the listing
|
|
@@ -856,6 +1023,21 @@ export class BridgeSession {
|
|
|
856
1023
|
// ── Tunnel handlers ────────────────────────────────────────────────
|
|
857
1024
|
handleTunnelRequest(payload) {
|
|
858
1025
|
const { requestId, method, url: reqUrl, headers, body, port } = payload;
|
|
1026
|
+
// SSRF guard: only proxy to ports in the allowlist (pinned to 127.0.0.1).
|
|
1027
|
+
if (!this.allowedTunnelPorts.has(port)) {
|
|
1028
|
+
this.log(`rejecting tunnel request to disallowed port ${port}`);
|
|
1029
|
+
if (headers.upgrade === "websocket") {
|
|
1030
|
+
this.send(createEnvelope({
|
|
1031
|
+
type: "tunnel.ws.close",
|
|
1032
|
+
sessionId: this.sessionId,
|
|
1033
|
+
payload: { requestId, code: 1008, reason: `Port ${port} is not allowed` },
|
|
1034
|
+
}));
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
this.sendTunnelError(requestId, 403, `Port ${port} is not allowed`);
|
|
1038
|
+
}
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
859
1041
|
// WebSocket upgrade request
|
|
860
1042
|
if (headers.upgrade === "websocket") {
|
|
861
1043
|
this.handleTunnelWsUpgrade(requestId, port, reqUrl);
|
|
@@ -1067,7 +1249,7 @@ export class BridgeSession {
|
|
|
1067
1249
|
cwd,
|
|
1068
1250
|
projectName: basename(cwd),
|
|
1069
1251
|
provider,
|
|
1070
|
-
scrollback: new ScrollbackBuffer(
|
|
1252
|
+
scrollback: new ScrollbackBuffer(SCROLLBACK_LINES),
|
|
1071
1253
|
outputSeq: 0,
|
|
1072
1254
|
statusSeq: 0,
|
|
1073
1255
|
status: "running",
|
|
@@ -1104,6 +1286,20 @@ export class BridgeSession {
|
|
|
1104
1286
|
payload: { exitCode, signal },
|
|
1105
1287
|
}));
|
|
1106
1288
|
this.sendTerminalList();
|
|
1289
|
+
// Reap the exited terminal (and its scrollback) after a grace period so
|
|
1290
|
+
// clients can replay final output, then free the memory. Tracked so the
|
|
1291
|
+
// timer can be cleared on stop().
|
|
1292
|
+
const reapTimer = setTimeout(() => {
|
|
1293
|
+
this.exitedTerminalTimers.delete(terminalId);
|
|
1294
|
+
const t = this.terminals.get(terminalId);
|
|
1295
|
+
if (t && t.status === "exited") {
|
|
1296
|
+
this.terminals.delete(terminalId);
|
|
1297
|
+
this.log(`reaped exited terminal ${terminalId}`);
|
|
1298
|
+
}
|
|
1299
|
+
}, EXITED_TERMINAL_GRACE_MS);
|
|
1300
|
+
if (typeof reapTimer.unref === "function")
|
|
1301
|
+
reapTimer.unref();
|
|
1302
|
+
this.exitedTerminalTimers.set(terminalId, reapTimer);
|
|
1107
1303
|
// If all terminals exited, close the session
|
|
1108
1304
|
const allExited = [...this.terminals.values()].every((t) => t.status === "exited");
|
|
1109
1305
|
if (allExited) {
|
|
@@ -1111,6 +1307,9 @@ export class BridgeSession {
|
|
|
1111
1307
|
setTimeout(() => {
|
|
1112
1308
|
this.stopHeartbeat();
|
|
1113
1309
|
this.socket?.close();
|
|
1310
|
+
// Self-clean process-level resources (embedded gateway + PID file)
|
|
1311
|
+
// since the PTYs exited naturally rather than via a signal handler.
|
|
1312
|
+
void this.onAllTerminalsExited?.();
|
|
1114
1313
|
}, 500);
|
|
1115
1314
|
process.exitCode = exitCode ?? 0;
|
|
1116
1315
|
}
|
|
@@ -1798,6 +1997,60 @@ export class BridgeSession {
|
|
|
1798
1997
|
}
|
|
1799
1998
|
catch { /* ignore parse errors */ }
|
|
1800
1999
|
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Remove ALL LinkShell hook entries (any marker) from a JSON config file.
|
|
2002
|
+
* Used on startup to sweep stale entries left behind by a crashed instance,
|
|
2003
|
+
* whose curl ports are now dead. Matches via isLinkShellHookEntry rather than
|
|
2004
|
+
* a single marker so entries from previous runs are also cleaned.
|
|
2005
|
+
*/
|
|
2006
|
+
sweepLinkShellHookEntries(configPath) {
|
|
2007
|
+
if (!existsSync(configPath))
|
|
2008
|
+
return;
|
|
2009
|
+
try {
|
|
2010
|
+
// Re-read immediately before write to minimize the window for a
|
|
2011
|
+
// concurrent writer to clobber our changes.
|
|
2012
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
2013
|
+
const hooks = raw.hooks;
|
|
2014
|
+
if (!hooks)
|
|
2015
|
+
return;
|
|
2016
|
+
let changed = false;
|
|
2017
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
2018
|
+
if (!Array.isArray(entries))
|
|
2019
|
+
continue;
|
|
2020
|
+
const filtered = entries.filter((entry) => !isLinkShellHookEntry(entry));
|
|
2021
|
+
if (filtered.length !== entries.length) {
|
|
2022
|
+
changed = true;
|
|
2023
|
+
if (filtered.length === 0) {
|
|
2024
|
+
delete hooks[eventName];
|
|
2025
|
+
}
|
|
2026
|
+
else {
|
|
2027
|
+
hooks[eventName] = filtered;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
if (changed) {
|
|
2032
|
+
if (Object.keys(hooks).length === 0) {
|
|
2033
|
+
delete raw.hooks;
|
|
2034
|
+
}
|
|
2035
|
+
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
2036
|
+
this.log(`swept stale LinkShell hook entries from ${configPath}`);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
catch { /* ignore parse errors */ }
|
|
2040
|
+
}
|
|
2041
|
+
/** Sweep stale LinkShell hook entries from known shared config files on startup. */
|
|
2042
|
+
sweepStaleHookConfigs() {
|
|
2043
|
+
const home = homedir();
|
|
2044
|
+
const candidates = [
|
|
2045
|
+
join(home, ".claude", "settings.json"),
|
|
2046
|
+
join(home, ".codex", "hooks.json"),
|
|
2047
|
+
join(home, ".gemini", "settings.json"),
|
|
2048
|
+
join(process.cwd(), "hooks.json"), // copilot (per-cwd)
|
|
2049
|
+
];
|
|
2050
|
+
for (const path of candidates) {
|
|
2051
|
+
this.sweepLinkShellHookEntries(path);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
1801
2054
|
send(message) {
|
|
1802
2055
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
1803
2056
|
return;
|
|
@@ -1834,6 +2087,47 @@ export class BridgeSession {
|
|
|
1834
2087
|
this.heartbeatTimer = undefined;
|
|
1835
2088
|
}
|
|
1836
2089
|
}
|
|
2090
|
+
// Transport-layer keepalive. WS ping/pong detects a half-open socket (network
|
|
2091
|
+
// dropped without a TCP FIN, e.g. laptop sleep / Wi-Fi loss) that would
|
|
2092
|
+
// otherwise hang silently. If the previous ping's pong never arrived, the
|
|
2093
|
+
// socket is dead → terminate it, which fires `close` → scheduleReconnect.
|
|
2094
|
+
startWsPing() {
|
|
2095
|
+
this.stopWsPing();
|
|
2096
|
+
this.awaitingPong = false;
|
|
2097
|
+
this.wsPingTimer = setInterval(() => {
|
|
2098
|
+
const sock = this.socket;
|
|
2099
|
+
if (!sock || sock.readyState !== WebSocket.OPEN)
|
|
2100
|
+
return;
|
|
2101
|
+
if (this.awaitingPong) {
|
|
2102
|
+
// No pong since the last ping → half-open. Forcibly close so we reconnect.
|
|
2103
|
+
process.stderr.write("[bridge] no pong from gateway; terminating dead connection\n");
|
|
2104
|
+
this.awaitingPong = false;
|
|
2105
|
+
try {
|
|
2106
|
+
sock.terminate();
|
|
2107
|
+
}
|
|
2108
|
+
catch {
|
|
2109
|
+
// terminate may throw if already closing; close handler still runs.
|
|
2110
|
+
}
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
this.awaitingPong = true;
|
|
2114
|
+
try {
|
|
2115
|
+
sock.ping();
|
|
2116
|
+
}
|
|
2117
|
+
catch {
|
|
2118
|
+
// ping failed → let the next tick terminate, or close already fired.
|
|
2119
|
+
}
|
|
2120
|
+
}, WS_PING_INTERVAL);
|
|
2121
|
+
if (typeof this.wsPingTimer.unref === "function")
|
|
2122
|
+
this.wsPingTimer.unref();
|
|
2123
|
+
}
|
|
2124
|
+
stopWsPing() {
|
|
2125
|
+
if (this.wsPingTimer) {
|
|
2126
|
+
clearInterval(this.wsPingTimer);
|
|
2127
|
+
this.wsPingTimer = undefined;
|
|
2128
|
+
}
|
|
2129
|
+
this.awaitingPong = false;
|
|
2130
|
+
}
|
|
1837
2131
|
startScreenCapture(fps, quality, scale) {
|
|
1838
2132
|
if (!this.options.screen) {
|
|
1839
2133
|
this.log("screen sharing not enabled (use --screen)");
|
|
@@ -1892,17 +2186,22 @@ export class BridgeSession {
|
|
|
1892
2186
|
}
|
|
1893
2187
|
}
|
|
1894
2188
|
scheduleReconnect() {
|
|
1895
|
-
if (this.reconnecting)
|
|
2189
|
+
if (this.reconnecting || this.stopped || this.exited)
|
|
1896
2190
|
return;
|
|
1897
|
-
// In daemon mode, never give up — reset attempts after hitting max
|
|
1898
|
-
if (this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
|
|
1899
|
-
process.stderr.write("[bridge] max reconnect attempts reached, resetting counter and continuing...\n");
|
|
1900
|
-
this.reconnectAttempts = 0;
|
|
1901
|
-
}
|
|
1902
2191
|
this.reconnecting = true;
|
|
1903
|
-
|
|
2192
|
+
// Exponential backoff capped at RECONNECT_MAX_DELAY, with full jitter so a
|
|
2193
|
+
// fleet of hosts doesn't stampede a restarting gateway. The attempt counter
|
|
2194
|
+
// only ever grows toward the cap — it is NEVER reset to "give up"; the host
|
|
2195
|
+
// keeps trying forever until stop()/exit.
|
|
2196
|
+
const exponent = Math.min(this.reconnectAttempts, RECONNECT_MAX_EXPONENT);
|
|
2197
|
+
const base = Math.min(RECONNECT_BASE_DELAY * 2 ** exponent, RECONNECT_MAX_DELAY);
|
|
2198
|
+
const delay = Math.round(base * (0.5 + Math.random() * 0.5));
|
|
1904
2199
|
this.reconnectAttempts++;
|
|
1905
|
-
|
|
2200
|
+
// Quiet logging: only the first retry of an outage and every 10th attempt,
|
|
2201
|
+
// so a multi-hour offline window doesn't flood the daemon log.
|
|
2202
|
+
if (this.reconnectAttempts === 1 || this.reconnectAttempts % 10 === 0) {
|
|
2203
|
+
process.stderr.write(`[bridge] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})\n`);
|
|
2204
|
+
}
|
|
1906
2205
|
this.reconnectTimer = setTimeout(() => {
|
|
1907
2206
|
this.reconnectTimer = undefined;
|
|
1908
2207
|
if (this.stopped || this.exited) {
|
|
@@ -1919,6 +2218,7 @@ export class BridgeSession {
|
|
|
1919
2218
|
this.stopped = true;
|
|
1920
2219
|
this.exited = true;
|
|
1921
2220
|
this.stopHeartbeat();
|
|
2221
|
+
this.stopWsPing();
|
|
1922
2222
|
this.stopScreenCapture();
|
|
1923
2223
|
this.agentSession?.stop();
|
|
1924
2224
|
this.agentSession = undefined;
|
|
@@ -1937,6 +2237,11 @@ export class BridgeSession {
|
|
|
1937
2237
|
ws.close(1001, "Session stopped");
|
|
1938
2238
|
}
|
|
1939
2239
|
this.tunnelSockets.clear();
|
|
2240
|
+
// Clear pending exited-terminal reap timers.
|
|
2241
|
+
for (const timer of this.exitedTerminalTimers.values()) {
|
|
2242
|
+
clearTimeout(timer);
|
|
2243
|
+
}
|
|
2244
|
+
this.exitedTerminalTimers.clear();
|
|
1940
2245
|
for (const term of this.terminals.values()) {
|
|
1941
2246
|
this.cleanupHookServer(term);
|
|
1942
2247
|
if (term.status === "running")
|