linkshell-cli 0.5.2 → 0.5.4
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 +32 -0
- package/dist/cli/src/runtime/bridge-session.js +214 -12
- 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 +229 -14
|
@@ -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 {
|
|
9
9
|
agentV2MessageRoute,
|
|
10
10
|
createEnvelope,
|
|
@@ -43,6 +43,17 @@ export interface BridgeSessionOptions {
|
|
|
43
43
|
agentUi?: boolean;
|
|
44
44
|
agentProvider?: AgentProvider;
|
|
45
45
|
agentCommand?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Confinement root for client-driven filesystem operations
|
|
48
|
+
* (terminal.browse / terminal.file.read / terminal.mkdir).
|
|
49
|
+
* Defaults to the bridge's cwd. Paths resolving outside this root are rejected.
|
|
50
|
+
*/
|
|
51
|
+
fileRoot?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Ports the tunnel proxy is allowed to connect to on 127.0.0.1.
|
|
54
|
+
* Defaults to DEFAULT_TUNNEL_PORTS when omitted.
|
|
55
|
+
*/
|
|
56
|
+
allowedTunnelPorts?: number[];
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
const HEARTBEAT_INTERVAL = 15_000;
|
|
@@ -51,6 +62,16 @@ const RECONNECT_MAX_DELAY = 30_000;
|
|
|
51
62
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
52
63
|
const DEFAULT_TERMINAL_ID = "default";
|
|
53
64
|
const HOOK_BODY_LIMIT = 256 * 1024;
|
|
65
|
+
const SCROLLBACK_LINES = 500;
|
|
66
|
+
// Upper bound on concurrent live terminals — forceNew removed the cwd-dedup
|
|
67
|
+
// ceiling, so bound growth to avoid PTY/fd exhaustion from a runaway client.
|
|
68
|
+
const MAX_TERMINALS = 12;
|
|
69
|
+
// How long to keep an exited terminal (with its scrollback) in memory so the
|
|
70
|
+
// client can replay its final output before it is reaped.
|
|
71
|
+
const EXITED_TERMINAL_GRACE_MS = 30_000;
|
|
72
|
+
// Default ports the bridge will proxy tunnel requests to when no explicit
|
|
73
|
+
// allowlist is configured. Covers the common dev servers the app tunnels to.
|
|
74
|
+
const DEFAULT_TUNNEL_PORTS = [3000, 3001, 4321, 5173, 5174, 8080, 8000, 8081];
|
|
54
75
|
const PERMISSION_REQUEST_TIMEOUT_MS = Number(
|
|
55
76
|
process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
|
|
56
77
|
);
|
|
@@ -332,6 +353,7 @@ export class BridgeSession {
|
|
|
332
353
|
private reconnectAttempts = 0;
|
|
333
354
|
private reconnecting = false;
|
|
334
355
|
private sessionId = "";
|
|
356
|
+
private hostToken: string | undefined;
|
|
335
357
|
private exited = false;
|
|
336
358
|
private stopped = false;
|
|
337
359
|
private permissionStacks = new Map<string, Array<{
|
|
@@ -351,10 +373,51 @@ export class BridgeSession {
|
|
|
351
373
|
private agentSession: AgentSessionProxy | undefined;
|
|
352
374
|
private agentWorkspace: AgentWorkspaceProxy | undefined;
|
|
353
375
|
private machineIdentity: MachineIdentity | undefined;
|
|
376
|
+
// Confinement root for client-driven filesystem ops; resolved once at construction.
|
|
377
|
+
private readonly fileRoot: string;
|
|
378
|
+
// Ports the tunnel proxy may connect to on 127.0.0.1.
|
|
379
|
+
private readonly allowedTunnelPorts: Set<number>;
|
|
380
|
+
// Pending deletions of exited terminals (terminalId → timer) so we can clear on stop().
|
|
381
|
+
private exitedTerminalTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
382
|
+
// Invoked when all terminals have exited naturally — closes embedded gateway,
|
|
383
|
+
// removes daemon PID file, etc. Set by the CLI entry point.
|
|
384
|
+
private onAllTerminalsExited: (() => void | Promise<void>) | undefined;
|
|
354
385
|
|
|
355
386
|
constructor(options: BridgeSessionOptions) {
|
|
356
387
|
this.options = options;
|
|
357
388
|
this.sessionId = options.sessionId ?? "";
|
|
389
|
+
// Confine client-driven file ops to the user's home by default (single-user
|
|
390
|
+
// self-hosted model: the owner browses their own projects under ~). This
|
|
391
|
+
// still blocks /etc, /usr, other users' dirs. Override via --file-root.
|
|
392
|
+
this.fileRoot = resolve(options.fileRoot ?? homedir());
|
|
393
|
+
this.allowedTunnelPorts = new Set(
|
|
394
|
+
options.allowedTunnelPorts && options.allowedTunnelPorts.length > 0
|
|
395
|
+
? options.allowedTunnelPorts
|
|
396
|
+
: DEFAULT_TUNNEL_PORTS,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Register a callback invoked once all terminals exit naturally (process self-cleanup). */
|
|
401
|
+
setOnAllTerminalsExited(handler: () => void | Promise<void>): void {
|
|
402
|
+
this.onAllTerminalsExited = handler;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Resolve a client-supplied path inside the confinement root.
|
|
407
|
+
* Returns the absolute resolved path, or null if it escapes the root.
|
|
408
|
+
*/
|
|
409
|
+
private resolveConfinedPath(clientPath: string): string | null {
|
|
410
|
+
const expanded = clientPath.startsWith("~")
|
|
411
|
+
? clientPath.replace(/^~/, homedir())
|
|
412
|
+
: clientPath;
|
|
413
|
+
// Resolve relative paths against the confinement root, not process.cwd().
|
|
414
|
+
const target = isAbsolute(expanded)
|
|
415
|
+
? resolve(expanded)
|
|
416
|
+
: resolve(this.fileRoot, expanded);
|
|
417
|
+
const rel = relative(this.fileRoot, target);
|
|
418
|
+
if (rel === "") return target; // the root itself
|
|
419
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
420
|
+
return target;
|
|
358
421
|
}
|
|
359
422
|
|
|
360
423
|
private log(msg: string): void {
|
|
@@ -373,6 +436,9 @@ export class BridgeSession {
|
|
|
373
436
|
`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`,
|
|
374
437
|
);
|
|
375
438
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
439
|
+
// Sweep stale LinkShell hook entries (dead curl ports) left by a prior crash
|
|
440
|
+
// before we write fresh ones for this session.
|
|
441
|
+
this.sweepStaleHookConfigs();
|
|
376
442
|
if (!this.sessionId) {
|
|
377
443
|
await this.createPairing();
|
|
378
444
|
}
|
|
@@ -421,9 +487,14 @@ export class BridgeSession {
|
|
|
421
487
|
const body = (await res.json()) as {
|
|
422
488
|
sessionId: string;
|
|
423
489
|
pairingCode: string;
|
|
490
|
+
hostToken?: string;
|
|
424
491
|
expiresAt: string;
|
|
425
492
|
};
|
|
426
493
|
this.sessionId = body.sessionId;
|
|
494
|
+
// Secret token proving we are the legitimate host for this session; sent on
|
|
495
|
+
// every host WS connect so a third party who learns the sessionId can't
|
|
496
|
+
// hijack the host role.
|
|
497
|
+
this.hostToken = body.hostToken;
|
|
427
498
|
|
|
428
499
|
const pairingGateway = resolvePairingGateway(
|
|
429
500
|
this.options.gatewayHttpUrl,
|
|
@@ -494,11 +565,24 @@ export class BridgeSession {
|
|
|
494
565
|
url.searchParams.set("sessionId", this.sessionId);
|
|
495
566
|
url.searchParams.set("role", "host");
|
|
496
567
|
const authToken = await this.resolveAuthToken();
|
|
568
|
+
const wsOptions: WebSocket.ClientOptions = {};
|
|
569
|
+
const headers: Record<string, string> = {};
|
|
497
570
|
if (authToken) {
|
|
571
|
+
// Prefer the Authorization header so the token doesn't leak into URLs/logs.
|
|
572
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
573
|
+
// TODO: remove the query param once all gateways read the Authorization
|
|
574
|
+
// header. Kept for backward compatibility with older gateways that only
|
|
575
|
+
// read the `auth_token` query parameter.
|
|
498
576
|
url.searchParams.set("auth_token", authToken);
|
|
499
577
|
}
|
|
578
|
+
if (this.hostToken) {
|
|
579
|
+
headers["x-linkshell-host-token"] = this.hostToken;
|
|
580
|
+
}
|
|
581
|
+
if (Object.keys(headers).length > 0) {
|
|
582
|
+
wsOptions.headers = headers;
|
|
583
|
+
}
|
|
500
584
|
|
|
501
|
-
this.socket = new WebSocket(url);
|
|
585
|
+
this.socket = new WebSocket(url, wsOptions);
|
|
502
586
|
|
|
503
587
|
this.socket.on("open", () => {
|
|
504
588
|
process.stderr.write(
|
|
@@ -625,10 +709,24 @@ export class BridgeSession {
|
|
|
625
709
|
case "terminal.spawn": {
|
|
626
710
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
627
711
|
const normalizedCwd = resolve(p.cwd);
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
)
|
|
712
|
+
// Cap concurrent terminals — forceNew removed the cwd-dedup ceiling, so
|
|
713
|
+
// bound growth explicitly to avoid PTY/fd exhaustion from a runaway client.
|
|
714
|
+
const liveCount = [...this.terminals.values()].filter((t) => t.status === "running").length;
|
|
715
|
+
if (liveCount >= MAX_TERMINALS) {
|
|
716
|
+
this.send(createEnvelope({
|
|
717
|
+
type: "session.error",
|
|
718
|
+
sessionId: this.sessionId,
|
|
719
|
+
payload: { code: "too_many_terminals", message: `最多同时打开 ${MAX_TERMINALS} 个终端` },
|
|
720
|
+
}));
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
// Dedup by cwd UNLESS the client explicitly asks for a fresh terminal
|
|
724
|
+
// (forceNew lets web open multiple tabs in the same directory).
|
|
725
|
+
const existing = p.forceNew
|
|
726
|
+
? undefined
|
|
727
|
+
: [...this.terminals.values()].find(
|
|
728
|
+
(t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
|
|
729
|
+
);
|
|
632
730
|
if (existing) {
|
|
633
731
|
this.send(createEnvelope({
|
|
634
732
|
type: "terminal.spawned",
|
|
@@ -669,9 +767,15 @@ export class BridgeSession {
|
|
|
669
767
|
}
|
|
670
768
|
case "terminal.browse": {
|
|
671
769
|
const p = parseTypedPayload("terminal.browse", envelope.payload);
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
770
|
+
const browsePath = this.resolveConfinedPath(p.path);
|
|
771
|
+
if (browsePath === null) {
|
|
772
|
+
this.send(createEnvelope({
|
|
773
|
+
type: "terminal.browse.result",
|
|
774
|
+
sessionId: this.sessionId,
|
|
775
|
+
payload: { path: p.path, entries: [], error: "Path is outside the allowed root", requestId: p.requestId },
|
|
776
|
+
}));
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
675
779
|
try {
|
|
676
780
|
const entries = readdirSync(browsePath, { withFileTypes: true })
|
|
677
781
|
.filter((d) => !d.name.startsWith(".") && (d.isDirectory() || (p.includeFiles && d.isFile())))
|
|
@@ -706,8 +810,22 @@ export class BridgeSession {
|
|
|
706
810
|
}
|
|
707
811
|
case "terminal.file.read": {
|
|
708
812
|
const p = parseTypedPayload("terminal.file.read", envelope.payload);
|
|
709
|
-
const
|
|
710
|
-
|
|
813
|
+
const filePath = this.resolveConfinedPath(p.path);
|
|
814
|
+
if (filePath === null) {
|
|
815
|
+
this.send(createEnvelope({
|
|
816
|
+
type: "terminal.file.read.result",
|
|
817
|
+
sessionId: this.sessionId,
|
|
818
|
+
payload: {
|
|
819
|
+
path: p.path,
|
|
820
|
+
content: "",
|
|
821
|
+
encoding: "utf8",
|
|
822
|
+
truncated: false,
|
|
823
|
+
error: "Path is outside the allowed root",
|
|
824
|
+
requestId: p.requestId,
|
|
825
|
+
},
|
|
826
|
+
}));
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
711
829
|
try {
|
|
712
830
|
const stats = statSync(filePath);
|
|
713
831
|
if (!stats.isFile()) {
|
|
@@ -755,8 +873,15 @@ export class BridgeSession {
|
|
|
755
873
|
}
|
|
756
874
|
case "terminal.mkdir": {
|
|
757
875
|
const p = parseTypedPayload("terminal.mkdir", envelope.payload);
|
|
758
|
-
const
|
|
759
|
-
|
|
876
|
+
const dirPath = this.resolveConfinedPath(p.path);
|
|
877
|
+
if (dirPath === null) {
|
|
878
|
+
this.send(createEnvelope({
|
|
879
|
+
type: "terminal.browse.result",
|
|
880
|
+
sessionId: this.sessionId,
|
|
881
|
+
payload: { path: p.path, entries: [], error: "Path is outside the allowed root" },
|
|
882
|
+
}));
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
760
885
|
try {
|
|
761
886
|
mkdirSync(dirPath, { recursive: true });
|
|
762
887
|
// Browse the parent to refresh the listing
|
|
@@ -1017,6 +1142,21 @@ export class BridgeSession {
|
|
|
1017
1142
|
}): void {
|
|
1018
1143
|
const { requestId, method, url: reqUrl, headers, body, port } = payload;
|
|
1019
1144
|
|
|
1145
|
+
// SSRF guard: only proxy to ports in the allowlist (pinned to 127.0.0.1).
|
|
1146
|
+
if (!this.allowedTunnelPorts.has(port)) {
|
|
1147
|
+
this.log(`rejecting tunnel request to disallowed port ${port}`);
|
|
1148
|
+
if (headers.upgrade === "websocket") {
|
|
1149
|
+
this.send(createEnvelope({
|
|
1150
|
+
type: "tunnel.ws.close",
|
|
1151
|
+
sessionId: this.sessionId,
|
|
1152
|
+
payload: { requestId, code: 1008, reason: `Port ${port} is not allowed` },
|
|
1153
|
+
}));
|
|
1154
|
+
} else {
|
|
1155
|
+
this.sendTunnelError(requestId, 403, `Port ${port} is not allowed`);
|
|
1156
|
+
}
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1020
1160
|
// WebSocket upgrade request
|
|
1021
1161
|
if (headers.upgrade === "websocket") {
|
|
1022
1162
|
this.handleTunnelWsUpgrade(requestId, port, reqUrl);
|
|
@@ -1278,7 +1418,7 @@ export class BridgeSession {
|
|
|
1278
1418
|
cwd,
|
|
1279
1419
|
projectName: basename(cwd),
|
|
1280
1420
|
provider,
|
|
1281
|
-
scrollback: new ScrollbackBuffer(
|
|
1421
|
+
scrollback: new ScrollbackBuffer(SCROLLBACK_LINES),
|
|
1282
1422
|
outputSeq: 0,
|
|
1283
1423
|
statusSeq: 0,
|
|
1284
1424
|
status: "running",
|
|
@@ -1318,6 +1458,20 @@ export class BridgeSession {
|
|
|
1318
1458
|
}));
|
|
1319
1459
|
this.sendTerminalList();
|
|
1320
1460
|
|
|
1461
|
+
// Reap the exited terminal (and its scrollback) after a grace period so
|
|
1462
|
+
// clients can replay final output, then free the memory. Tracked so the
|
|
1463
|
+
// timer can be cleared on stop().
|
|
1464
|
+
const reapTimer = setTimeout(() => {
|
|
1465
|
+
this.exitedTerminalTimers.delete(terminalId);
|
|
1466
|
+
const t = this.terminals.get(terminalId);
|
|
1467
|
+
if (t && t.status === "exited") {
|
|
1468
|
+
this.terminals.delete(terminalId);
|
|
1469
|
+
this.log(`reaped exited terminal ${terminalId}`);
|
|
1470
|
+
}
|
|
1471
|
+
}, EXITED_TERMINAL_GRACE_MS);
|
|
1472
|
+
if (typeof reapTimer.unref === "function") reapTimer.unref();
|
|
1473
|
+
this.exitedTerminalTimers.set(terminalId, reapTimer);
|
|
1474
|
+
|
|
1321
1475
|
// If all terminals exited, close the session
|
|
1322
1476
|
const allExited = [...this.terminals.values()].every((t) => t.status === "exited");
|
|
1323
1477
|
if (allExited) {
|
|
@@ -1325,6 +1479,9 @@ export class BridgeSession {
|
|
|
1325
1479
|
setTimeout(() => {
|
|
1326
1480
|
this.stopHeartbeat();
|
|
1327
1481
|
this.socket?.close();
|
|
1482
|
+
// Self-clean process-level resources (embedded gateway + PID file)
|
|
1483
|
+
// since the PTYs exited naturally rather than via a signal handler.
|
|
1484
|
+
void this.onAllTerminalsExited?.();
|
|
1328
1485
|
}, 500);
|
|
1329
1486
|
process.exitCode = exitCode ?? 0;
|
|
1330
1487
|
}
|
|
@@ -2046,6 +2203,59 @@ export class BridgeSession {
|
|
|
2046
2203
|
} catch { /* ignore parse errors */ }
|
|
2047
2204
|
}
|
|
2048
2205
|
|
|
2206
|
+
/**
|
|
2207
|
+
* Remove ALL LinkShell hook entries (any marker) from a JSON config file.
|
|
2208
|
+
* Used on startup to sweep stale entries left behind by a crashed instance,
|
|
2209
|
+
* whose curl ports are now dead. Matches via isLinkShellHookEntry rather than
|
|
2210
|
+
* a single marker so entries from previous runs are also cleaned.
|
|
2211
|
+
*/
|
|
2212
|
+
private sweepLinkShellHookEntries(configPath: string): void {
|
|
2213
|
+
if (!existsSync(configPath)) return;
|
|
2214
|
+
try {
|
|
2215
|
+
// Re-read immediately before write to minimize the window for a
|
|
2216
|
+
// concurrent writer to clobber our changes.
|
|
2217
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
2218
|
+
const hooks = raw.hooks as Record<string, unknown[]> | undefined;
|
|
2219
|
+
if (!hooks) return;
|
|
2220
|
+
|
|
2221
|
+
let changed = false;
|
|
2222
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
2223
|
+
if (!Array.isArray(entries)) continue;
|
|
2224
|
+
const filtered = entries.filter((entry) => !isLinkShellHookEntry(entry));
|
|
2225
|
+
if (filtered.length !== entries.length) {
|
|
2226
|
+
changed = true;
|
|
2227
|
+
if (filtered.length === 0) {
|
|
2228
|
+
delete hooks[eventName];
|
|
2229
|
+
} else {
|
|
2230
|
+
hooks[eventName] = filtered;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
if (changed) {
|
|
2236
|
+
if (Object.keys(hooks).length === 0) {
|
|
2237
|
+
delete raw.hooks;
|
|
2238
|
+
}
|
|
2239
|
+
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
2240
|
+
this.log(`swept stale LinkShell hook entries from ${configPath}`);
|
|
2241
|
+
}
|
|
2242
|
+
} catch { /* ignore parse errors */ }
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/** Sweep stale LinkShell hook entries from known shared config files on startup. */
|
|
2246
|
+
private sweepStaleHookConfigs(): void {
|
|
2247
|
+
const home = homedir();
|
|
2248
|
+
const candidates = [
|
|
2249
|
+
join(home, ".claude", "settings.json"),
|
|
2250
|
+
join(home, ".codex", "hooks.json"),
|
|
2251
|
+
join(home, ".gemini", "settings.json"),
|
|
2252
|
+
join(process.cwd(), "hooks.json"), // copilot (per-cwd)
|
|
2253
|
+
];
|
|
2254
|
+
for (const path of candidates) {
|
|
2255
|
+
this.sweepLinkShellHookEntries(path);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2049
2259
|
private send(message: Envelope): void {
|
|
2050
2260
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
2051
2261
|
return;
|
|
@@ -2207,6 +2417,11 @@ export class BridgeSession {
|
|
|
2207
2417
|
ws.close(1001, "Session stopped");
|
|
2208
2418
|
}
|
|
2209
2419
|
this.tunnelSockets.clear();
|
|
2420
|
+
// Clear pending exited-terminal reap timers.
|
|
2421
|
+
for (const timer of this.exitedTerminalTimers.values()) {
|
|
2422
|
+
clearTimeout(timer);
|
|
2423
|
+
}
|
|
2424
|
+
this.exitedTerminalTimers.clear();
|
|
2210
2425
|
for (const term of this.terminals.values()) {
|
|
2211
2426
|
this.cleanupHookServer(term);
|
|
2212
2427
|
if (term.status === "running") term.pty.kill();
|