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.
@@ -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
+ }