hop2 0.9.0 → 0.10.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/AGENTS.md +33 -0
- package/INTEGRATION.md +77 -85
- package/MULTI-AGENT-CHECKLIST.md +37 -0
- package/README-MCP.md +232 -0
- package/README.md +209 -148
- package/demo/30s-product-demo.md +98 -0
- package/demo/README.md +106 -0
- package/demo/director.mjs +236 -0
- package/demo/drive-hop-terminal.mjs +70 -0
- package/demo/hop-demo-lib.mjs +525 -0
- package/demo/record-hop-view.mjs +26 -0
- package/demo/static-preview-server.mjs +45 -0
- package/demo/stitch-demo-video.mjs +128 -0
- package/hay/CHANGELOG.md +6 -0
- package/hay/DEV_NOTES.md +25 -0
- package/hay/README.md +136 -198
- package/hay/apps/cli/src/index.ts +321 -67
- package/hay/apps/server/src/index.ts +4 -3
- package/hay/apps/server/src/lib.ts +5 -4
- package/hay/apps/server/src/pty.ts +20 -7
- package/hay/apps/server/src/rooms.ts +226 -12
- package/hay/apps/server/test/rooms.test.ts +82 -3
- package/hay/apps/web/src/App.tsx +487 -112
- package/hay/apps/web/src/components/MobileKeyboard.tsx +43 -2
- package/hay/apps/web/src/styles.css +259 -29
- package/hay/package-lock.json +4 -4
- package/hay/packages/shared/src/protocol.ts +8 -0
- package/hay-web/assets/index-BmWjq_Nh.css +32 -0
- package/hay-web/assets/index-D9aOC5Zt.js +63 -0
- package/hay-web/assets/{index-FDfdolCs.js.map → index-D9aOC5Zt.js.map} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +6801 -1714
- package/lib/naming-themes.js +194 -0
- package/lib/terminal-access.js +17 -0
- package/mcp/hop-mcp.js +4730 -0
- package/package.json +6 -3
- package/scripts/hay-host.js +189 -0
- package/sessions.html +378 -26
- package/setup-mcp.js +443 -0
- package/tests/hop-mcp.integration.test.cjs +3014 -0
- package/tests/terminal-access.unit.test.cjs +21 -0
- package/tests/terminal-api.integration.test.cjs +298 -0
- package/.hop-tmux.conf +0 -18
- package/hay-web/assets/index-CTTzApRp.css +0 -32
- package/hay-web/assets/index-FDfdolCs.js +0 -60
- package/tunnel-shell.js +0 -254
|
@@ -2,13 +2,20 @@ import os from "node:os";
|
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import pty, { IPty } from "node-pty-prebuilt-multiarch";
|
|
4
4
|
|
|
5
|
-
export type
|
|
5
|
+
export type PtyFactoryOptions = {
|
|
6
6
|
cols: number;
|
|
7
7
|
rows: number;
|
|
8
|
-
cwd
|
|
9
|
-
|
|
8
|
+
cwd: string;
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
shell?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type PtyFactory = (options: PtyFactoryOptions) => IPty;
|
|
10
14
|
|
|
11
|
-
const resolveShell = () => {
|
|
15
|
+
const resolveShell = (shellOverride?: string) => {
|
|
16
|
+
if (shellOverride) {
|
|
17
|
+
return shellOverride;
|
|
18
|
+
}
|
|
12
19
|
if (process.env.SHELL) {
|
|
13
20
|
return process.env.SHELL;
|
|
14
21
|
}
|
|
@@ -18,7 +25,7 @@ const resolveShell = () => {
|
|
|
18
25
|
return "/bin/bash";
|
|
19
26
|
};
|
|
20
27
|
|
|
21
|
-
export const createPty: PtyFactory = ({ cols, rows, cwd }) => {
|
|
28
|
+
export const createPty: PtyFactory = ({ cols, rows, cwd, env: envOverrides, shell }) => {
|
|
22
29
|
const mode = process.env.PTY_MODE ?? "native";
|
|
23
30
|
if (mode === "mock") {
|
|
24
31
|
return createMockPty();
|
|
@@ -30,14 +37,20 @@ export const createPty: PtyFactory = ({ cols, rows, cwd }) => {
|
|
|
30
37
|
env[key] = value;
|
|
31
38
|
}
|
|
32
39
|
}
|
|
40
|
+
if (envOverrides && typeof envOverrides === "object") {
|
|
41
|
+
for (const [key, value] of Object.entries(envOverrides)) {
|
|
42
|
+
if (!key || typeof value !== "string") continue;
|
|
43
|
+
env[key] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
33
46
|
env.TERM = "xterm-256color";
|
|
34
47
|
if (!env.COLORTERM) {
|
|
35
48
|
env.COLORTERM = "truecolor";
|
|
36
49
|
}
|
|
37
|
-
return pty.spawn(resolveShell(), [], {
|
|
50
|
+
return pty.spawn(resolveShell(shell), [], {
|
|
38
51
|
cols,
|
|
39
52
|
rows,
|
|
40
|
-
cwd
|
|
53
|
+
cwd,
|
|
41
54
|
env
|
|
42
55
|
});
|
|
43
56
|
} catch (error) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { readlink } from "node:fs";
|
|
2
4
|
import type { IPty } from "node-pty-prebuilt-multiarch";
|
|
3
5
|
import { pickPresenceColor, safeParseClientMessage } from "hay-shared";
|
|
4
6
|
import type { ClientMessage, PresenceClient, ServerMessage } from "hay-shared";
|
|
@@ -21,6 +23,19 @@ export type ClientInfo = {
|
|
|
21
23
|
rows: number;
|
|
22
24
|
};
|
|
23
25
|
|
|
26
|
+
export type RoomCreateOptions = {
|
|
27
|
+
cwd: string;
|
|
28
|
+
env?: Record<string, string>;
|
|
29
|
+
shell?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type RoomSummary = {
|
|
33
|
+
id: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
liveCwd: string;
|
|
36
|
+
clientCount: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
24
39
|
const MAX_BUFFER_SIZE = 200_000;
|
|
25
40
|
// Keep rooms alive indefinitely; only explicit kill/remove should end a session.
|
|
26
41
|
const CLEANUP_DELAY_MS = 0;
|
|
@@ -67,6 +82,8 @@ class ClientState {
|
|
|
67
82
|
export class Room extends EventEmitter {
|
|
68
83
|
id: string;
|
|
69
84
|
private pty: IPty;
|
|
85
|
+
private readonly initialCwd: string;
|
|
86
|
+
private liveCwd: string;
|
|
70
87
|
private clients = new Map<string, ClientState>();
|
|
71
88
|
private collabMode = true;
|
|
72
89
|
private controllerId: string | null = null;
|
|
@@ -77,13 +94,23 @@ export class Room extends EventEmitter {
|
|
|
77
94
|
private alternateScreen = false;
|
|
78
95
|
private cursorHidden = false;
|
|
79
96
|
private controlSequenceTail = "";
|
|
97
|
+
private oscBuffer = "";
|
|
80
98
|
private cleanupTimer: NodeJS.Timeout | null = null;
|
|
81
99
|
private ended = false;
|
|
100
|
+
private cwdPollTimer: NodeJS.Timeout | null = null;
|
|
82
101
|
|
|
83
|
-
constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number },
|
|
102
|
+
constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
|
|
84
103
|
super();
|
|
85
104
|
this.id = id;
|
|
86
|
-
this.
|
|
105
|
+
this.initialCwd = options.cwd;
|
|
106
|
+
this.liveCwd = options.cwd;
|
|
107
|
+
this.pty = ptyFactory({
|
|
108
|
+
cols: initialSize.cols,
|
|
109
|
+
rows: initialSize.rows,
|
|
110
|
+
cwd: options.cwd,
|
|
111
|
+
env: options.env,
|
|
112
|
+
shell: options.shell
|
|
113
|
+
});
|
|
87
114
|
this.activeCols = initialSize.cols;
|
|
88
115
|
this.activeRows = initialSize.rows;
|
|
89
116
|
|
|
@@ -91,6 +118,7 @@ export class Room extends EventEmitter {
|
|
|
91
118
|
this.outputBuffer = clampBuffer(this.outputBuffer + data);
|
|
92
119
|
this.updateTerminalState(data);
|
|
93
120
|
this.broadcast({ type: "output", data });
|
|
121
|
+
this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
|
|
94
122
|
});
|
|
95
123
|
|
|
96
124
|
const handleExit = (exitCode?: number | null, signal?: number | string) => {
|
|
@@ -118,6 +146,9 @@ export class Room extends EventEmitter {
|
|
|
118
146
|
handleExit(exitCode, signal);
|
|
119
147
|
});
|
|
120
148
|
}
|
|
149
|
+
|
|
150
|
+
// Start PID-based cwd polling as fallback for shells without OSC 7
|
|
151
|
+
this.startCwdPolling();
|
|
121
152
|
}
|
|
122
153
|
|
|
123
154
|
attachClient(info: ClientInfo, socket: SocketAdapter) {
|
|
@@ -158,6 +189,12 @@ export class Room extends EventEmitter {
|
|
|
158
189
|
} satisfies ServerMessage));
|
|
159
190
|
}
|
|
160
191
|
|
|
192
|
+
// Send current cwd to the new client
|
|
193
|
+
socket.send(JSON.stringify({
|
|
194
|
+
type: "cwd_changed",
|
|
195
|
+
cwd: this.liveCwd
|
|
196
|
+
} satisfies ServerMessage));
|
|
197
|
+
|
|
161
198
|
this.broadcastPresence();
|
|
162
199
|
|
|
163
200
|
socket.onMessage((payload) => {
|
|
@@ -226,6 +263,8 @@ export class Room extends EventEmitter {
|
|
|
226
263
|
|
|
227
264
|
private updateTerminalState(data: string) {
|
|
228
265
|
const combined = this.controlSequenceTail + data;
|
|
266
|
+
|
|
267
|
+
// Parse CSI mode sequences (cursor visibility, alternate screen)
|
|
229
268
|
const regex = /\x1b\[\?([0-9;]*)([hl])/g;
|
|
230
269
|
let match: RegExpExecArray | null;
|
|
231
270
|
let nextAlternate = this.alternateScreen;
|
|
@@ -243,31 +282,177 @@ export class Room extends EventEmitter {
|
|
|
243
282
|
}
|
|
244
283
|
}
|
|
245
284
|
|
|
285
|
+
let stateChanged = false;
|
|
246
286
|
if (nextAlternate !== this.alternateScreen) {
|
|
247
287
|
if (DEBUG_STATE) {
|
|
248
288
|
console.log(`[hay] room=${this.id} alternateScreen=${nextAlternate}`);
|
|
249
289
|
}
|
|
250
290
|
this.alternateScreen = nextAlternate;
|
|
291
|
+
stateChanged = true;
|
|
251
292
|
}
|
|
252
293
|
if (nextCursorHidden !== this.cursorHidden) {
|
|
253
294
|
if (DEBUG_STATE) {
|
|
254
295
|
console.log(`[hay] room=${this.id} cursorHidden=${nextCursorHidden}`);
|
|
255
296
|
}
|
|
256
297
|
this.cursorHidden = nextCursorHidden;
|
|
298
|
+
stateChanged = true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (stateChanged) {
|
|
302
|
+
this.emit("pty_state", {
|
|
303
|
+
roomId: this.id,
|
|
304
|
+
alternateScreen: this.alternateScreen,
|
|
305
|
+
cursorHidden: this.cursorHidden,
|
|
306
|
+
timestamp: now()
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Parse OSC 7 — only scan when ESC is present (fast path: skip most data chunks)
|
|
311
|
+
if (data.includes("\x1b]7;") || this.oscBuffer) {
|
|
312
|
+
this.parseOsc7(data);
|
|
257
313
|
}
|
|
258
314
|
|
|
259
315
|
this.controlSequenceTail = combined.slice(-CONTROL_SEQUENCE_TAIL);
|
|
260
316
|
}
|
|
261
317
|
|
|
318
|
+
// Regex: matches OSC 7 with BEL (\x07) or ST (\x1b\\) terminator
|
|
319
|
+
private static readonly OSC7_RE = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g;
|
|
320
|
+
|
|
321
|
+
private parseOsc7(data: string) {
|
|
322
|
+
const chunk = this.oscBuffer + data;
|
|
323
|
+
this.oscBuffer = "";
|
|
324
|
+
|
|
325
|
+
// Fast regex scan — no allocations when there's no match
|
|
326
|
+
Room.OSC7_RE.lastIndex = 0;
|
|
327
|
+
let match: RegExpExecArray | null;
|
|
328
|
+
while ((match = Room.OSC7_RE.exec(chunk)) !== null) {
|
|
329
|
+
this.handleOsc7Uri(match[1]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check for a partial OSC 7 at the end of the chunk (split across packets)
|
|
333
|
+
const tailIdx = chunk.lastIndexOf("\x1b]7;");
|
|
334
|
+
if (tailIdx !== -1) {
|
|
335
|
+
const tail = chunk.slice(tailIdx);
|
|
336
|
+
// If the regex didn't consume this position, it's incomplete
|
|
337
|
+
if (tail.indexOf("\x07") === -1 && tail.indexOf("\x1b\\") === -1 && tail.length < 512) {
|
|
338
|
+
this.oscBuffer = tail;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private handleOsc7Uri(uri: string) {
|
|
344
|
+
// Parse file://hostname/path URI
|
|
345
|
+
try {
|
|
346
|
+
let cwdPath: string;
|
|
347
|
+
if (uri.startsWith("file://")) {
|
|
348
|
+
// Extract path portion, skipping hostname
|
|
349
|
+
const withoutScheme = uri.slice(7);
|
|
350
|
+
const slashIdx = withoutScheme.indexOf("/");
|
|
351
|
+
if (slashIdx === -1) return;
|
|
352
|
+
cwdPath = decodeURIComponent(withoutScheme.slice(slashIdx));
|
|
353
|
+
} else if (uri.startsWith("/")) {
|
|
354
|
+
cwdPath = decodeURIComponent(uri);
|
|
355
|
+
} else {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (cwdPath && cwdPath !== this.liveCwd) {
|
|
360
|
+
if (DEBUG_STATE) {
|
|
361
|
+
console.log(`[hay] room=${this.id} cwd=${cwdPath}`);
|
|
362
|
+
}
|
|
363
|
+
this.liveCwd = cwdPath;
|
|
364
|
+
this.broadcast({ type: "cwd_changed", cwd: cwdPath });
|
|
365
|
+
this.emit("cwd_changed", { roomId: this.id, cwd: cwdPath, timestamp: now() });
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// Malformed URI — ignore
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Start polling PID-based cwd as fallback for shells that don't emit OSC 7.
|
|
374
|
+
* Uses a long interval to avoid system overhead (lsof is expensive on macOS).
|
|
375
|
+
* OSC 7 parsing is the primary mechanism; this is a best-effort fallback.
|
|
376
|
+
*/
|
|
377
|
+
startCwdPolling(intervalMs = 15000) {
|
|
378
|
+
this.stopCwdPolling();
|
|
379
|
+
const pid = (this.pty as unknown as { pid?: number }).pid;
|
|
380
|
+
if (!pid) return;
|
|
381
|
+
|
|
382
|
+
const updateCwd = (cwdPath: string) => {
|
|
383
|
+
if (cwdPath && cwdPath !== this.liveCwd) {
|
|
384
|
+
if (DEBUG_STATE) {
|
|
385
|
+
console.log(`[hay] room=${this.id} cwd(poll)=${cwdPath}`);
|
|
386
|
+
}
|
|
387
|
+
this.liveCwd = cwdPath;
|
|
388
|
+
this.broadcast({ type: "cwd_changed", cwd: cwdPath });
|
|
389
|
+
this.emit("cwd_changed", { roomId: this.id, cwd: cwdPath, timestamp: now() });
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const poll = () => {
|
|
394
|
+
if (this.ended) return;
|
|
395
|
+
if (process.platform === "darwin") {
|
|
396
|
+
execFile("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { timeout: 5000 }, (err, stdout) => {
|
|
397
|
+
if (err || !stdout) return;
|
|
398
|
+
for (const line of stdout.split("\n")) {
|
|
399
|
+
if (line.startsWith("n/")) {
|
|
400
|
+
updateCwd(line.slice(1));
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
} else if (process.platform === "linux") {
|
|
406
|
+
readlink(`/proc/${pid}/cwd`, (err, target) => {
|
|
407
|
+
if (err || !target) return;
|
|
408
|
+
updateCwd(target);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
this.cwdPollTimer = setInterval(poll, intervalMs);
|
|
414
|
+
// Initial poll with a generous delay to not interfere with startup
|
|
415
|
+
setTimeout(poll, 3000);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
stopCwdPolling() {
|
|
419
|
+
if (this.cwdPollTimer) {
|
|
420
|
+
clearInterval(this.cwdPollTimer);
|
|
421
|
+
this.cwdPollTimer = null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
262
425
|
kill() {
|
|
426
|
+
this.stopCwdPolling();
|
|
263
427
|
if (this.cleanupTimer) {
|
|
264
428
|
clearTimeout(this.cleanupTimer);
|
|
265
429
|
this.cleanupTimer = null;
|
|
266
430
|
}
|
|
267
|
-
|
|
431
|
+
try {
|
|
432
|
+
this.pty.kill();
|
|
433
|
+
} catch (e) {
|
|
434
|
+
/* swallow – PTY may already be dead */
|
|
435
|
+
}
|
|
268
436
|
this.endSession({ exitCode: null, signal: null, message: "Session terminated" });
|
|
269
437
|
}
|
|
270
438
|
|
|
439
|
+
notifySessionRenamed(displayName: string) {
|
|
440
|
+
this.broadcast({ type: "session_renamed", displayName });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
sendSystemInput(data: string, source = "system") {
|
|
444
|
+
if (!data) return;
|
|
445
|
+
this.pty.write(data);
|
|
446
|
+
this.emit("pty_input", {
|
|
447
|
+
roomId: this.id,
|
|
448
|
+
clientId: null,
|
|
449
|
+
actor: "system",
|
|
450
|
+
source,
|
|
451
|
+
data,
|
|
452
|
+
timestamp: now()
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
271
456
|
private handleInput(client: ClientState, data: string) {
|
|
272
457
|
if (!this.collabMode && this.controllerId !== client.id) {
|
|
273
458
|
client.socket.send(
|
|
@@ -277,6 +462,7 @@ export class Room extends EventEmitter {
|
|
|
277
462
|
}
|
|
278
463
|
client.lastActive = now();
|
|
279
464
|
this.pty.write(data);
|
|
465
|
+
this.emit("pty_input", { roomId: this.id, clientId: client.id, data, timestamp: now() });
|
|
280
466
|
}
|
|
281
467
|
|
|
282
468
|
private handleResize(client: ClientState, cols: number, rows: number) {
|
|
@@ -297,6 +483,7 @@ export class Room extends EventEmitter {
|
|
|
297
483
|
this.activeClientId = client.id;
|
|
298
484
|
// Broadcast the new active size to other clients so they can adjust
|
|
299
485
|
this.broadcastActiveSize(client);
|
|
486
|
+
this.emit("pty_resize", { roomId: this.id, clientId: client.id, cols, rows, timestamp: now() });
|
|
300
487
|
}
|
|
301
488
|
}
|
|
302
489
|
|
|
@@ -363,38 +550,57 @@ export class Room extends EventEmitter {
|
|
|
363
550
|
private endSession(payload: { exitCode: number | null; signal: string | null; message: string }) {
|
|
364
551
|
if (this.ended) return;
|
|
365
552
|
this.ended = true;
|
|
553
|
+
this.stopCwdPolling();
|
|
366
554
|
if (this.cleanupTimer) {
|
|
367
555
|
clearTimeout(this.cleanupTimer);
|
|
368
556
|
this.cleanupTimer = null;
|
|
369
557
|
}
|
|
370
558
|
const message = JSON.stringify({ type: "session_ended", ...payload } satisfies ServerMessage);
|
|
371
559
|
for (const client of this.clients.values()) {
|
|
372
|
-
|
|
373
|
-
client.socket.
|
|
560
|
+
try {
|
|
561
|
+
if (client.socket.isOpen()) {
|
|
562
|
+
client.socket.send(message);
|
|
563
|
+
}
|
|
564
|
+
client.socket.close();
|
|
565
|
+
} catch (e) {
|
|
566
|
+
/* swallow – don't let one bad socket kill the loop */
|
|
374
567
|
}
|
|
375
|
-
client.socket.close();
|
|
376
568
|
}
|
|
377
569
|
this.clients.clear();
|
|
570
|
+
this.emit("session_end", { roomId: this.id, ...payload, timestamp: now() });
|
|
378
571
|
this.emit("empty");
|
|
379
572
|
}
|
|
573
|
+
|
|
574
|
+
getSummary(): RoomSummary {
|
|
575
|
+
return {
|
|
576
|
+
id: this.id,
|
|
577
|
+
cwd: this.initialCwd,
|
|
578
|
+
liveCwd: this.liveCwd,
|
|
579
|
+
clientCount: this.clients.size
|
|
580
|
+
};
|
|
581
|
+
}
|
|
380
582
|
}
|
|
381
583
|
|
|
382
584
|
export class RoomManager {
|
|
383
585
|
private rooms = new Map<string, Room>();
|
|
384
586
|
private ptyFactory: PtyFactory;
|
|
385
|
-
private cwd?: string;
|
|
386
587
|
|
|
387
|
-
constructor(ptyFactory: PtyFactory
|
|
588
|
+
constructor(ptyFactory: PtyFactory) {
|
|
388
589
|
this.ptyFactory = ptyFactory;
|
|
389
|
-
this.cwd = cwd;
|
|
390
590
|
}
|
|
391
591
|
|
|
392
|
-
getRoom(roomId: string, initialSize: { cols: number; rows: number },
|
|
592
|
+
getRoom(roomId: string, initialSize: { cols: number; rows: number }, cwdOrOptions: string | RoomCreateOptions) {
|
|
393
593
|
const existing = this.rooms.get(roomId);
|
|
394
594
|
if (existing) {
|
|
395
595
|
return existing;
|
|
396
596
|
}
|
|
397
|
-
const
|
|
597
|
+
const options = typeof cwdOrOptions === "string"
|
|
598
|
+
? { cwd: cwdOrOptions }
|
|
599
|
+
: cwdOrOptions;
|
|
600
|
+
if (!options.cwd) {
|
|
601
|
+
throw new Error(`[RoomManager] cwd is required when creating room "${roomId}"`);
|
|
602
|
+
}
|
|
603
|
+
const room = new Room(roomId, this.ptyFactory, initialSize, options);
|
|
398
604
|
room.on("empty", () => {
|
|
399
605
|
this.rooms.delete(roomId);
|
|
400
606
|
});
|
|
@@ -409,7 +615,11 @@ export class RoomManager {
|
|
|
409
615
|
closeRoom(roomId: string) {
|
|
410
616
|
const room = this.rooms.get(roomId);
|
|
411
617
|
if (!room) return;
|
|
412
|
-
|
|
618
|
+
try {
|
|
619
|
+
room.kill();
|
|
620
|
+
} catch (e) {
|
|
621
|
+
/* swallow – ensure we still remove the room from the map */
|
|
622
|
+
}
|
|
413
623
|
this.rooms.delete(roomId);
|
|
414
624
|
}
|
|
415
625
|
|
|
@@ -418,4 +628,8 @@ export class RoomManager {
|
|
|
418
628
|
this.closeRoom(roomId);
|
|
419
629
|
}
|
|
420
630
|
}
|
|
631
|
+
|
|
632
|
+
listRooms(): RoomSummary[] {
|
|
633
|
+
return Array.from(this.rooms.values()).map((room) => room.getSummary());
|
|
634
|
+
}
|
|
421
635
|
}
|
|
@@ -81,6 +81,25 @@ const findMessages = (socket: FakeSocket, type: string) => {
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
describe("Room", () => {
|
|
84
|
+
it("passes room create options to the PTY factory", () => {
|
|
85
|
+
let capturedOptions: any = null;
|
|
86
|
+
const factory: PtyFactory = (options) => {
|
|
87
|
+
capturedOptions = options;
|
|
88
|
+
return new FakePty() as any;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const manager = new RoomManager(factory);
|
|
92
|
+
manager.getRoom("opts", { cols: 80, rows: 24 }, {
|
|
93
|
+
cwd: "/tmp/demo",
|
|
94
|
+
env: { HISTFILE: "/tmp/.history" },
|
|
95
|
+
shell: "/bin/zsh",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(capturedOptions?.cwd).toBe("/tmp/demo");
|
|
99
|
+
expect(capturedOptions?.env?.HISTFILE).toBe("/tmp/.history");
|
|
100
|
+
expect(capturedOptions?.shell).toBe("/bin/zsh");
|
|
101
|
+
});
|
|
102
|
+
|
|
84
103
|
it("broadcasts pty output to all clients", () => {
|
|
85
104
|
let ptyInstance: FakePty | null = null;
|
|
86
105
|
const factory: PtyFactory = () => {
|
|
@@ -89,7 +108,7 @@ describe("Room", () => {
|
|
|
89
108
|
};
|
|
90
109
|
|
|
91
110
|
const manager = new RoomManager(factory);
|
|
92
|
-
const room = manager.getRoom("alpha", { cols: 80, rows: 24 });
|
|
111
|
+
const room = manager.getRoom("alpha", { cols: 80, rows: 24 }, "/tmp");
|
|
93
112
|
const socketA = new FakeSocket();
|
|
94
113
|
const socketB = new FakeSocket();
|
|
95
114
|
|
|
@@ -113,7 +132,7 @@ describe("Room", () => {
|
|
|
113
132
|
};
|
|
114
133
|
|
|
115
134
|
const manager = new RoomManager(factory);
|
|
116
|
-
const room = manager.getRoom("bravo", { cols: 80, rows: 24 });
|
|
135
|
+
const room = manager.getRoom("bravo", { cols: 80, rows: 24 }, "/tmp");
|
|
117
136
|
const socketA = new FakeSocket();
|
|
118
137
|
const socketB = new FakeSocket();
|
|
119
138
|
|
|
@@ -130,7 +149,7 @@ describe("Room", () => {
|
|
|
130
149
|
|
|
131
150
|
it("updates presence on disconnect", () => {
|
|
132
151
|
const manager = new RoomManager(() => new FakePty() as any);
|
|
133
|
-
const room = manager.getRoom("charlie", { cols: 80, rows: 24 });
|
|
152
|
+
const room = manager.getRoom("charlie", { cols: 80, rows: 24 }, "/tmp");
|
|
134
153
|
const socketA = new FakeSocket();
|
|
135
154
|
const socketB = new FakeSocket();
|
|
136
155
|
|
|
@@ -143,4 +162,64 @@ describe("Room", () => {
|
|
|
143
162
|
const latest = presenceMessages.at(-1) as any;
|
|
144
163
|
expect(latest?.clients).toHaveLength(1);
|
|
145
164
|
});
|
|
165
|
+
|
|
166
|
+
it("emits room PTY lifecycle events for embedding servers", () => {
|
|
167
|
+
let ptyInstance: FakePty | null = null;
|
|
168
|
+
const factory: PtyFactory = () => {
|
|
169
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
170
|
+
return ptyInstance as any;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const manager = new RoomManager(factory);
|
|
174
|
+
const room = manager.getRoom("events", { cols: 80, rows: 24 }, "/tmp");
|
|
175
|
+
const socket = new FakeSocket();
|
|
176
|
+
room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, socket);
|
|
177
|
+
|
|
178
|
+
const inputs: any[] = [];
|
|
179
|
+
const outputs: any[] = [];
|
|
180
|
+
const resizes: any[] = [];
|
|
181
|
+
const states: any[] = [];
|
|
182
|
+
const ends: any[] = [];
|
|
183
|
+
room.on("pty_input", (payload) => inputs.push(payload));
|
|
184
|
+
room.on("pty_output", (payload) => outputs.push(payload));
|
|
185
|
+
room.on("pty_resize", (payload) => resizes.push(payload));
|
|
186
|
+
room.on("pty_state", (payload) => states.push(payload));
|
|
187
|
+
room.on("session_end", (payload) => ends.push(payload));
|
|
188
|
+
|
|
189
|
+
socket.emitMessage({ type: "input", data: "pwd\n" });
|
|
190
|
+
ptyInstance?.emit("ok\n");
|
|
191
|
+
ptyInstance?.emit("\x1b[?1049h");
|
|
192
|
+
ptyInstance?.emit("\x1b[?1049l");
|
|
193
|
+
socket.emitMessage({ type: "resize", cols: 100, rows: 30 });
|
|
194
|
+
room.kill();
|
|
195
|
+
|
|
196
|
+
expect(inputs.at(-1)?.clientId).toBe("a");
|
|
197
|
+
expect(inputs.at(-1)?.data).toBe("pwd\n");
|
|
198
|
+
expect(outputs.some((payload) => payload?.data === "ok\n")).toBe(true);
|
|
199
|
+
expect(resizes.at(-1)?.cols).toBe(100);
|
|
200
|
+
expect(resizes.at(-1)?.rows).toBe(30);
|
|
201
|
+
expect(states.at(0)?.alternateScreen).toBe(true);
|
|
202
|
+
expect(states.at(1)?.alternateScreen).toBe(false);
|
|
203
|
+
expect(ends.at(-1)?.message).toBe("Session terminated");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("supports system input writes for host-level session setup", () => {
|
|
207
|
+
let ptyInstance: FakePty | null = null;
|
|
208
|
+
const factory: PtyFactory = () => {
|
|
209
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
210
|
+
return ptyInstance as any;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const manager = new RoomManager(factory);
|
|
214
|
+
const room = manager.getRoom("system", { cols: 80, rows: 24 }, "/tmp");
|
|
215
|
+
const inputs: any[] = [];
|
|
216
|
+
room.on("pty_input", (payload) => inputs.push(payload));
|
|
217
|
+
|
|
218
|
+
room.sendSystemInput("export HISTFILE=/tmp/demo.history\\n", "hop");
|
|
219
|
+
|
|
220
|
+
expect(ptyInstance?.writes.at(-1)).toBe("export HISTFILE=/tmp/demo.history\\n");
|
|
221
|
+
expect(inputs.at(-1)?.actor).toBe("system");
|
|
222
|
+
expect(inputs.at(-1)?.source).toBe("hop");
|
|
223
|
+
expect(inputs.at(-1)?.clientId).toBe(null);
|
|
224
|
+
});
|
|
146
225
|
});
|