clawmatrix 0.1.22 → 0.2.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.
- package/README.md +4 -1
- package/package.json +4 -2
- package/src/acp-proxy.ts +2073 -0
- package/src/audit.ts +42 -0
- package/src/auth.ts +2 -3
- package/src/cli.ts +76 -2
- package/src/cluster-service.ts +243 -3
- package/src/compat.ts +84 -3
- package/src/config.ts +117 -4
- package/src/connection.ts +290 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +132 -87
- package/src/identity.ts +95 -0
- package/src/index.ts +539 -45
- package/src/knowledge-sync.ts +777 -205
- package/src/local-tools.ts +9 -2
- package/src/model-proxy.ts +358 -110
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +270 -38
- package/src/rate-limiter.ts +88 -0
- package/src/router.ts +32 -10
- package/src/sentinel-manager.ts +142 -0
- package/src/sentinel.ts +618 -0
- package/src/task-activity.ts +74 -0
- package/src/terminal.ts +566 -0
- package/src/tool-proxy.ts +127 -3
- package/src/tools/cluster-acp.ts +237 -0
- package/src/tools/cluster-batch.ts +76 -0
- package/src/tools/cluster-diagnostic.ts +174 -0
- package/src/tools/cluster-edit.ts +70 -0
- package/src/tools/cluster-peers.ts +59 -14
- package/src/tools/cluster-terminal.ts +232 -0
- package/src/tools/cluster-tool.ts +26 -11
- package/src/types.ts +477 -3
- package/src/web.ts +2 -2
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerminalManager — manages interactive PTY sessions over the WebSocket mesh.
|
|
3
|
+
*
|
|
4
|
+
* Receiver side: spawns PTY processes and streams output to requester.
|
|
5
|
+
* Requester side: buffers remote terminal output for agent tool consumption.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import type { PeerManager } from "./peer-manager.ts";
|
|
10
|
+
import type { ClawMatrixConfig } from "./config.ts";
|
|
11
|
+
import { spawnPty, isPtyAvailable, type PtyHandle } from "./compat.ts";
|
|
12
|
+
import { debug } from "./debug.ts";
|
|
13
|
+
import type {
|
|
14
|
+
TerminalOpenRequest,
|
|
15
|
+
TerminalOpenResponse,
|
|
16
|
+
TerminalData,
|
|
17
|
+
TerminalResize,
|
|
18
|
+
TerminalCloseRequest,
|
|
19
|
+
TerminalCloseResponse,
|
|
20
|
+
} from "./types.ts";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_SESSION_TTL = 1_800_000; // 30 min
|
|
23
|
+
const DEFAULT_MAX_SESSIONS = 3;
|
|
24
|
+
const OUTPUT_BATCH_MS = 50; // batch output to reduce frame count
|
|
25
|
+
const CLEANUP_INTERVAL = 60_000;
|
|
26
|
+
const MAX_OUTPUT_BUFFER = 256 * 1024; // 256 KB per session
|
|
27
|
+
|
|
28
|
+
// ── Receiver: local PTY session ─────────────────────────────────
|
|
29
|
+
|
|
30
|
+
interface PtySession {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
requesterId: string; // nodeId of the requester
|
|
33
|
+
frameId: string; // original terminal_open frame id (for correlation)
|
|
34
|
+
pty: PtyHandle;
|
|
35
|
+
lastActivity: number;
|
|
36
|
+
pendingOutput: string; // batched output before flush
|
|
37
|
+
flushTimer: ReturnType<typeof setTimeout> | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Requester: remote terminal session ──────────────────────────
|
|
41
|
+
|
|
42
|
+
interface RemoteSession {
|
|
43
|
+
sessionId: string;
|
|
44
|
+
nodeId: string;
|
|
45
|
+
frameId: string;
|
|
46
|
+
outputBuffer: string; // accumulated output (base64 decoded)
|
|
47
|
+
lastActivity: number;
|
|
48
|
+
closed: boolean;
|
|
49
|
+
exitCode?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PendingOpen {
|
|
53
|
+
resolve: (sessionId: string) => void;
|
|
54
|
+
reject: (err: Error) => void;
|
|
55
|
+
timer: ReturnType<typeof setTimeout>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class TerminalManager {
|
|
59
|
+
private config: ClawMatrixConfig;
|
|
60
|
+
private peerManager: PeerManager;
|
|
61
|
+
|
|
62
|
+
// Receiver side
|
|
63
|
+
private sessions = new Map<string, PtySession>();
|
|
64
|
+
|
|
65
|
+
// Requester side
|
|
66
|
+
private remoteSessions = new Map<string, RemoteSession>();
|
|
67
|
+
private pendingOpens = new Map<string, PendingOpen>(); // keyed by frame id
|
|
68
|
+
|
|
69
|
+
private cleanupTimer: ReturnType<typeof setTimeout>;
|
|
70
|
+
|
|
71
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
72
|
+
this.config = config;
|
|
73
|
+
this.peerManager = peerManager;
|
|
74
|
+
|
|
75
|
+
// Periodic cleanup of idle sessions
|
|
76
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
77
|
+
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Receiver: handle incoming terminal_open ─────────────────────
|
|
81
|
+
|
|
82
|
+
async handleOpenRequest(frame: TerminalOpenRequest): Promise<void> {
|
|
83
|
+
const nodeId = this.config.nodeId;
|
|
84
|
+
const termConfig = this.config.terminal;
|
|
85
|
+
|
|
86
|
+
// Check if terminal is explicitly disabled (enabled by default when config is omitted)
|
|
87
|
+
if (termConfig?.enabled === false) {
|
|
88
|
+
this.peerManager.sendTo(frame.from, {
|
|
89
|
+
type: "terminal_open_res",
|
|
90
|
+
id: frame.id,
|
|
91
|
+
from: nodeId,
|
|
92
|
+
to: frame.from,
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
payload: { success: false, error: "Terminal not enabled on this node" },
|
|
95
|
+
} as TerminalOpenResponse);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check PTY availability
|
|
100
|
+
if (!isPtyAvailable()) {
|
|
101
|
+
this.peerManager.sendTo(frame.from, {
|
|
102
|
+
type: "terminal_open_res",
|
|
103
|
+
id: frame.id,
|
|
104
|
+
from: nodeId,
|
|
105
|
+
to: frame.from,
|
|
106
|
+
timestamp: Date.now(),
|
|
107
|
+
payload: { success: false, error: "node-pty not available on this node" },
|
|
108
|
+
} as TerminalOpenResponse);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check allowFrom
|
|
113
|
+
if (termConfig?.allowFrom && termConfig.allowFrom.length > 0) {
|
|
114
|
+
if (!termConfig.allowFrom.includes(frame.from)) {
|
|
115
|
+
this.peerManager.sendTo(frame.from, {
|
|
116
|
+
type: "terminal_open_res",
|
|
117
|
+
id: frame.id,
|
|
118
|
+
from: nodeId,
|
|
119
|
+
to: frame.from,
|
|
120
|
+
timestamp: Date.now(),
|
|
121
|
+
payload: { success: false, error: "Terminal access denied for this node" },
|
|
122
|
+
} as TerminalOpenResponse);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check max sessions
|
|
128
|
+
const maxSessions = termConfig?.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
129
|
+
if (this.sessions.size >= maxSessions) {
|
|
130
|
+
this.peerManager.sendTo(frame.from, {
|
|
131
|
+
type: "terminal_open_res",
|
|
132
|
+
id: frame.id,
|
|
133
|
+
from: nodeId,
|
|
134
|
+
to: frame.from,
|
|
135
|
+
timestamp: Date.now(),
|
|
136
|
+
payload: { success: false, error: `Max terminal sessions reached (${maxSessions})` },
|
|
137
|
+
} as TerminalOpenResponse);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Spawn PTY
|
|
142
|
+
const shell = frame.payload.shell ?? termConfig?.shell ?? process.env.SHELL ?? "/bin/sh";
|
|
143
|
+
const sessionId = randomUUID();
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const pty = spawnPty(shell, [], {
|
|
147
|
+
cols: frame.payload.cols ?? 80,
|
|
148
|
+
rows: frame.payload.rows ?? 24,
|
|
149
|
+
cwd: frame.payload.cwd,
|
|
150
|
+
env: frame.payload.env,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const session: PtySession = {
|
|
154
|
+
sessionId,
|
|
155
|
+
requesterId: frame.from,
|
|
156
|
+
frameId: frame.id,
|
|
157
|
+
pty,
|
|
158
|
+
lastActivity: Date.now(),
|
|
159
|
+
pendingOutput: "",
|
|
160
|
+
flushTimer: null,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
this.sessions.set(sessionId, session);
|
|
164
|
+
|
|
165
|
+
// Stream PTY output back to requester (batched)
|
|
166
|
+
pty.onData((data: string) => {
|
|
167
|
+
session.lastActivity = Date.now();
|
|
168
|
+
session.pendingOutput += data;
|
|
169
|
+
|
|
170
|
+
if (!session.flushTimer) {
|
|
171
|
+
session.flushTimer = setTimeout(() => {
|
|
172
|
+
this.flushOutput(session);
|
|
173
|
+
}, OUTPUT_BATCH_MS);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Handle PTY exit
|
|
178
|
+
pty.onExit(({ exitCode }) => {
|
|
179
|
+
// Flush any remaining output
|
|
180
|
+
if (session.pendingOutput) {
|
|
181
|
+
this.flushOutput(session);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.peerManager.sendTo(session.requesterId, {
|
|
185
|
+
type: "terminal_close",
|
|
186
|
+
id: session.frameId,
|
|
187
|
+
from: nodeId,
|
|
188
|
+
to: session.requesterId,
|
|
189
|
+
timestamp: Date.now(),
|
|
190
|
+
payload: { sessionId, exitCode },
|
|
191
|
+
} as TerminalCloseRequest);
|
|
192
|
+
|
|
193
|
+
this.cleanupSession(sessionId);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
debug("terminal", `PTY session ${sessionId} opened for ${frame.from} (shell=${shell}, pid=${pty.pid})`);
|
|
197
|
+
|
|
198
|
+
this.peerManager.sendTo(frame.from, {
|
|
199
|
+
type: "terminal_open_res",
|
|
200
|
+
id: frame.id,
|
|
201
|
+
from: nodeId,
|
|
202
|
+
to: frame.from,
|
|
203
|
+
timestamp: Date.now(),
|
|
204
|
+
payload: { success: true, sessionId },
|
|
205
|
+
} as TerminalOpenResponse);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
this.peerManager.sendTo(frame.from, {
|
|
208
|
+
type: "terminal_open_res",
|
|
209
|
+
id: frame.id,
|
|
210
|
+
from: nodeId,
|
|
211
|
+
to: frame.from,
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
payload: { success: false, error: `Failed to spawn PTY: ${err instanceof Error ? err.message : String(err)}` },
|
|
214
|
+
} as TerminalOpenResponse);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private flushOutput(session: PtySession) {
|
|
219
|
+
if (session.flushTimer) {
|
|
220
|
+
clearTimeout(session.flushTimer);
|
|
221
|
+
session.flushTimer = null;
|
|
222
|
+
}
|
|
223
|
+
if (!session.pendingOutput) return;
|
|
224
|
+
|
|
225
|
+
const data = Buffer.from(session.pendingOutput).toString("base64");
|
|
226
|
+
session.pendingOutput = "";
|
|
227
|
+
|
|
228
|
+
this.peerManager.sendTo(session.requesterId, {
|
|
229
|
+
type: "terminal_data",
|
|
230
|
+
id: session.frameId,
|
|
231
|
+
from: this.config.nodeId,
|
|
232
|
+
to: session.requesterId,
|
|
233
|
+
timestamp: Date.now(),
|
|
234
|
+
payload: { sessionId: session.sessionId, data, direction: "output" },
|
|
235
|
+
} as TerminalData);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Receiver: handle incoming data (stdin from requester) ───────
|
|
239
|
+
|
|
240
|
+
handleDataReceiver(frame: TerminalData): void {
|
|
241
|
+
if (frame.payload.direction !== "input") return;
|
|
242
|
+
|
|
243
|
+
const session = this.sessions.get(frame.payload.sessionId);
|
|
244
|
+
if (!session) {
|
|
245
|
+
debug("terminal", `Data for unknown session ${frame.payload.sessionId}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
session.lastActivity = Date.now();
|
|
250
|
+
const decoded = Buffer.from(frame.payload.data, "base64").toString();
|
|
251
|
+
session.pty.write(decoded);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Receiver: handle resize ────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
handleResizeReceiver(frame: TerminalResize): void {
|
|
257
|
+
const session = this.sessions.get(frame.payload.sessionId);
|
|
258
|
+
if (!session) return;
|
|
259
|
+
|
|
260
|
+
session.lastActivity = Date.now();
|
|
261
|
+
session.pty.resize(frame.payload.cols, frame.payload.rows);
|
|
262
|
+
debug("terminal", `Session ${frame.payload.sessionId} resized to ${frame.payload.cols}x${frame.payload.rows}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Receiver: handle close request from requester ──────────────
|
|
266
|
+
|
|
267
|
+
handleCloseReceiver(frame: TerminalCloseRequest): void {
|
|
268
|
+
const session = this.sessions.get(frame.payload.sessionId);
|
|
269
|
+
if (!session) {
|
|
270
|
+
this.peerManager.sendTo(frame.from, {
|
|
271
|
+
type: "terminal_close_res",
|
|
272
|
+
id: frame.id,
|
|
273
|
+
from: this.config.nodeId,
|
|
274
|
+
to: frame.from,
|
|
275
|
+
timestamp: Date.now(),
|
|
276
|
+
payload: { success: false, error: "Session not found" },
|
|
277
|
+
} as TerminalCloseResponse);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
session.pty.kill();
|
|
282
|
+
this.cleanupSession(frame.payload.sessionId);
|
|
283
|
+
|
|
284
|
+
this.peerManager.sendTo(frame.from, {
|
|
285
|
+
type: "terminal_close_res",
|
|
286
|
+
id: frame.id,
|
|
287
|
+
from: this.config.nodeId,
|
|
288
|
+
to: frame.from,
|
|
289
|
+
timestamp: Date.now(),
|
|
290
|
+
payload: { success: true },
|
|
291
|
+
} as TerminalCloseResponse);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Requester: open a remote terminal session ──────────────────
|
|
295
|
+
|
|
296
|
+
async open(
|
|
297
|
+
nodeId: string,
|
|
298
|
+
opts: { shell?: string; cols?: number; rows?: number; cwd?: string; env?: Record<string, string> } = {},
|
|
299
|
+
): Promise<string> {
|
|
300
|
+
const frameId = randomUUID();
|
|
301
|
+
|
|
302
|
+
return new Promise<string>((resolve, reject) => {
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
this.pendingOpens.delete(frameId);
|
|
305
|
+
reject(new Error("Terminal open timeout"));
|
|
306
|
+
}, 30_000);
|
|
307
|
+
|
|
308
|
+
this.pendingOpens.set(frameId, { resolve, reject, timer });
|
|
309
|
+
|
|
310
|
+
this.peerManager.sendTo(nodeId, {
|
|
311
|
+
type: "terminal_open",
|
|
312
|
+
id: frameId,
|
|
313
|
+
from: this.config.nodeId,
|
|
314
|
+
to: nodeId,
|
|
315
|
+
timestamp: Date.now(),
|
|
316
|
+
payload: {
|
|
317
|
+
shell: opts.shell,
|
|
318
|
+
cols: opts.cols ?? 80,
|
|
319
|
+
rows: opts.rows ?? 24,
|
|
320
|
+
cwd: opts.cwd,
|
|
321
|
+
env: opts.env,
|
|
322
|
+
},
|
|
323
|
+
} as TerminalOpenRequest);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Requester: handle open response ────────────────────────────
|
|
328
|
+
|
|
329
|
+
handleOpenResponse(frame: TerminalOpenResponse): void {
|
|
330
|
+
const pending = this.pendingOpens.get(frame.id);
|
|
331
|
+
if (!pending) return;
|
|
332
|
+
|
|
333
|
+
clearTimeout(pending.timer);
|
|
334
|
+
this.pendingOpens.delete(frame.id);
|
|
335
|
+
|
|
336
|
+
if (frame.payload.success && frame.payload.sessionId) {
|
|
337
|
+
const remoteSession: RemoteSession = {
|
|
338
|
+
sessionId: frame.payload.sessionId,
|
|
339
|
+
nodeId: frame.from,
|
|
340
|
+
frameId: frame.id,
|
|
341
|
+
outputBuffer: "",
|
|
342
|
+
lastActivity: Date.now(),
|
|
343
|
+
closed: false,
|
|
344
|
+
};
|
|
345
|
+
this.remoteSessions.set(frame.payload.sessionId, remoteSession);
|
|
346
|
+
pending.resolve(frame.payload.sessionId);
|
|
347
|
+
} else {
|
|
348
|
+
pending.reject(new Error(frame.payload.error ?? "Terminal open failed"));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Requester: handle output data from remote ──────────────────
|
|
353
|
+
|
|
354
|
+
handleDataRequester(frame: TerminalData): void {
|
|
355
|
+
if (frame.payload.direction !== "output") return;
|
|
356
|
+
|
|
357
|
+
const session = this.remoteSessions.get(frame.payload.sessionId);
|
|
358
|
+
if (!session) return;
|
|
359
|
+
|
|
360
|
+
session.lastActivity = Date.now();
|
|
361
|
+
const decoded = Buffer.from(frame.payload.data, "base64").toString();
|
|
362
|
+
|
|
363
|
+
// Cap output buffer
|
|
364
|
+
session.outputBuffer += decoded;
|
|
365
|
+
if (session.outputBuffer.length > MAX_OUTPUT_BUFFER) {
|
|
366
|
+
session.outputBuffer = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Requester: handle remote close notification ────────────────
|
|
371
|
+
|
|
372
|
+
handleCloseRequester(frame: TerminalCloseRequest): void {
|
|
373
|
+
const session = this.remoteSessions.get(frame.payload.sessionId);
|
|
374
|
+
if (!session) return;
|
|
375
|
+
|
|
376
|
+
session.closed = true;
|
|
377
|
+
session.exitCode = frame.payload.exitCode;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
handleCloseResponse(frame: TerminalCloseResponse): void {
|
|
381
|
+
// Just acknowledgement, no action needed
|
|
382
|
+
debug("terminal", `Close response: success=${frame.payload.success}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Requester: send input to remote terminal ───────────────────
|
|
386
|
+
|
|
387
|
+
sendInput(sessionId: string, data: string): void {
|
|
388
|
+
const session = this.remoteSessions.get(sessionId);
|
|
389
|
+
if (!session || session.closed) {
|
|
390
|
+
throw new Error(`Terminal session ${sessionId} not found or closed`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
session.lastActivity = Date.now();
|
|
394
|
+
const encoded = Buffer.from(data).toString("base64");
|
|
395
|
+
|
|
396
|
+
this.peerManager.sendTo(session.nodeId, {
|
|
397
|
+
type: "terminal_data",
|
|
398
|
+
id: session.frameId,
|
|
399
|
+
from: this.config.nodeId,
|
|
400
|
+
to: session.nodeId,
|
|
401
|
+
timestamp: Date.now(),
|
|
402
|
+
payload: { sessionId, data: encoded, direction: "input" },
|
|
403
|
+
} as TerminalData);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Requester: resize remote terminal ──────────────────────────
|
|
407
|
+
|
|
408
|
+
resize(sessionId: string, cols: number, rows: number): void {
|
|
409
|
+
const session = this.remoteSessions.get(sessionId);
|
|
410
|
+
if (!session || session.closed) return;
|
|
411
|
+
|
|
412
|
+
session.lastActivity = Date.now();
|
|
413
|
+
|
|
414
|
+
this.peerManager.sendTo(session.nodeId, {
|
|
415
|
+
type: "terminal_resize",
|
|
416
|
+
id: session.frameId,
|
|
417
|
+
from: this.config.nodeId,
|
|
418
|
+
to: session.nodeId,
|
|
419
|
+
timestamp: Date.now(),
|
|
420
|
+
payload: { sessionId, cols, rows },
|
|
421
|
+
} as TerminalResize);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── Requester: close remote terminal ───────────────────────────
|
|
425
|
+
|
|
426
|
+
close(sessionId: string): void {
|
|
427
|
+
const session = this.remoteSessions.get(sessionId);
|
|
428
|
+
if (!session) return;
|
|
429
|
+
|
|
430
|
+
this.peerManager.sendTo(session.nodeId, {
|
|
431
|
+
type: "terminal_close",
|
|
432
|
+
id: randomUUID(),
|
|
433
|
+
from: this.config.nodeId,
|
|
434
|
+
to: session.nodeId,
|
|
435
|
+
timestamp: Date.now(),
|
|
436
|
+
payload: { sessionId },
|
|
437
|
+
} as TerminalCloseRequest);
|
|
438
|
+
|
|
439
|
+
this.remoteSessions.delete(sessionId);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Requester: read buffered output ────────────────────────────
|
|
443
|
+
|
|
444
|
+
readOutput(sessionId: string): { data: string; closed: boolean; exitCode?: number } {
|
|
445
|
+
const session = this.remoteSessions.get(sessionId);
|
|
446
|
+
if (!session) {
|
|
447
|
+
return { data: "", closed: true, exitCode: undefined };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const data = session.outputBuffer;
|
|
451
|
+
session.outputBuffer = "";
|
|
452
|
+
|
|
453
|
+
return { data, closed: session.closed, exitCode: session.exitCode };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── Requester: list active remote sessions ─────────────────────
|
|
457
|
+
|
|
458
|
+
listSessions(): Array<{ sessionId: string; nodeId: string; closed: boolean; exitCode?: number }> {
|
|
459
|
+
return Array.from(this.remoteSessions.values()).map((s) => ({
|
|
460
|
+
sessionId: s.sessionId,
|
|
461
|
+
nodeId: s.nodeId,
|
|
462
|
+
closed: s.closed,
|
|
463
|
+
exitCode: s.exitCode,
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── Frame dispatch helper (called from ClusterRuntime) ─────────
|
|
468
|
+
|
|
469
|
+
dispatchFrame(frame: TerminalOpenRequest | TerminalOpenResponse | TerminalData | TerminalResize | TerminalCloseRequest | TerminalCloseResponse): void {
|
|
470
|
+
switch (frame.type) {
|
|
471
|
+
case "terminal_open":
|
|
472
|
+
this.handleOpenRequest(frame as TerminalOpenRequest).catch((err) => {
|
|
473
|
+
debug("terminal", `Open request error: ${err}`);
|
|
474
|
+
});
|
|
475
|
+
break;
|
|
476
|
+
case "terminal_open_res":
|
|
477
|
+
this.handleOpenResponse(frame as TerminalOpenResponse);
|
|
478
|
+
break;
|
|
479
|
+
case "terminal_data": {
|
|
480
|
+
const df = frame as TerminalData;
|
|
481
|
+
if (df.payload.direction === "input") {
|
|
482
|
+
this.handleDataReceiver(df);
|
|
483
|
+
} else {
|
|
484
|
+
this.handleDataRequester(df);
|
|
485
|
+
}
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
case "terminal_resize":
|
|
489
|
+
this.handleResizeReceiver(frame as TerminalResize);
|
|
490
|
+
break;
|
|
491
|
+
case "terminal_close":
|
|
492
|
+
// Could be from requester (asking to close) or from receiver (PTY exited)
|
|
493
|
+
if (this.sessions.has((frame as TerminalCloseRequest).payload.sessionId)) {
|
|
494
|
+
// We are the receiver, requester asked to close
|
|
495
|
+
this.handleCloseReceiver(frame as TerminalCloseRequest);
|
|
496
|
+
} else {
|
|
497
|
+
// We are the requester, receiver notified PTY exit
|
|
498
|
+
this.handleCloseRequester(frame as TerminalCloseRequest);
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
case "terminal_close_res":
|
|
502
|
+
this.handleCloseResponse(frame as TerminalCloseResponse);
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── Cleanup ────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
private cleanup() {
|
|
510
|
+
const ttl = this.config.terminal?.sessionTTL ?? DEFAULT_SESSION_TTL;
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
|
|
513
|
+
// Cleanup idle receiver sessions
|
|
514
|
+
for (const [sessionId, session] of this.sessions) {
|
|
515
|
+
if (now - session.lastActivity > ttl) {
|
|
516
|
+
debug("terminal", `Session ${sessionId} timed out (idle ${Math.floor((now - session.lastActivity) / 1000)}s)`);
|
|
517
|
+
session.pty.kill();
|
|
518
|
+
|
|
519
|
+
this.peerManager.sendTo(session.requesterId, {
|
|
520
|
+
type: "terminal_close",
|
|
521
|
+
id: session.frameId,
|
|
522
|
+
from: this.config.nodeId,
|
|
523
|
+
to: session.requesterId,
|
|
524
|
+
timestamp: Date.now(),
|
|
525
|
+
payload: { sessionId, exitCode: -1 },
|
|
526
|
+
} as TerminalCloseRequest);
|
|
527
|
+
|
|
528
|
+
this.cleanupSession(sessionId);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Cleanup closed remote sessions
|
|
533
|
+
for (const [sessionId, session] of this.remoteSessions) {
|
|
534
|
+
if (session.closed && now - session.lastActivity > 60_000) {
|
|
535
|
+
this.remoteSessions.delete(sessionId);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private cleanupSession(sessionId: string) {
|
|
541
|
+
const session = this.sessions.get(sessionId);
|
|
542
|
+
if (session?.flushTimer) {
|
|
543
|
+
clearTimeout(session.flushTimer);
|
|
544
|
+
}
|
|
545
|
+
this.sessions.delete(sessionId);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
destroy() {
|
|
549
|
+
clearInterval(this.cleanupTimer);
|
|
550
|
+
|
|
551
|
+
// Kill all local PTY sessions
|
|
552
|
+
for (const [sessionId, session] of this.sessions) {
|
|
553
|
+
if (session.flushTimer) clearTimeout(session.flushTimer);
|
|
554
|
+
try { session.pty.kill(); } catch { /* ignore */ }
|
|
555
|
+
this.sessions.delete(sessionId);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Clear requester state
|
|
559
|
+
for (const [, pending] of this.pendingOpens) {
|
|
560
|
+
clearTimeout(pending.timer);
|
|
561
|
+
pending.reject(new Error("Terminal manager destroyed"));
|
|
562
|
+
}
|
|
563
|
+
this.pendingOpens.clear();
|
|
564
|
+
this.remoteSessions.clear();
|
|
565
|
+
}
|
|
566
|
+
}
|