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.
Files changed (46) hide show
  1. package/AGENTS.md +33 -0
  2. package/INTEGRATION.md +77 -85
  3. package/MULTI-AGENT-CHECKLIST.md +37 -0
  4. package/README-MCP.md +232 -0
  5. package/README.md +209 -148
  6. package/demo/30s-product-demo.md +98 -0
  7. package/demo/README.md +106 -0
  8. package/demo/director.mjs +236 -0
  9. package/demo/drive-hop-terminal.mjs +70 -0
  10. package/demo/hop-demo-lib.mjs +525 -0
  11. package/demo/record-hop-view.mjs +26 -0
  12. package/demo/static-preview-server.mjs +45 -0
  13. package/demo/stitch-demo-video.mjs +128 -0
  14. package/hay/CHANGELOG.md +6 -0
  15. package/hay/DEV_NOTES.md +25 -0
  16. package/hay/README.md +136 -198
  17. package/hay/apps/cli/src/index.ts +321 -67
  18. package/hay/apps/server/src/index.ts +4 -3
  19. package/hay/apps/server/src/lib.ts +5 -4
  20. package/hay/apps/server/src/pty.ts +20 -7
  21. package/hay/apps/server/src/rooms.ts +226 -12
  22. package/hay/apps/server/test/rooms.test.ts +82 -3
  23. package/hay/apps/web/src/App.tsx +487 -112
  24. package/hay/apps/web/src/components/MobileKeyboard.tsx +43 -2
  25. package/hay/apps/web/src/styles.css +259 -29
  26. package/hay/package-lock.json +4 -4
  27. package/hay/packages/shared/src/protocol.ts +8 -0
  28. package/hay-web/assets/index-BmWjq_Nh.css +32 -0
  29. package/hay-web/assets/index-D9aOC5Zt.js +63 -0
  30. package/hay-web/assets/{index-FDfdolCs.js.map → index-D9aOC5Zt.js.map} +1 -1
  31. package/hay-web/index.html +2 -2
  32. package/hop +6801 -1714
  33. package/lib/naming-themes.js +194 -0
  34. package/lib/terminal-access.js +17 -0
  35. package/mcp/hop-mcp.js +4730 -0
  36. package/package.json +6 -3
  37. package/scripts/hay-host.js +189 -0
  38. package/sessions.html +378 -26
  39. package/setup-mcp.js +443 -0
  40. package/tests/hop-mcp.integration.test.cjs +3014 -0
  41. package/tests/terminal-access.unit.test.cjs +21 -0
  42. package/tests/terminal-api.integration.test.cjs +298 -0
  43. package/.hop-tmux.conf +0 -18
  44. package/hay-web/assets/index-CTTzApRp.css +0 -32
  45. package/hay-web/assets/index-FDfdolCs.js +0 -60
  46. 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 PtyFactory = (options: {
5
+ export type PtyFactoryOptions = {
6
6
  cols: number;
7
7
  rows: number;
8
- cwd?: string;
9
- }) => IPty;
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: cwd ?? process.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 }, cwd?: string) {
102
+ constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
84
103
  super();
85
104
  this.id = id;
86
- this.pty = ptyFactory({ cols: initialSize.cols, rows: initialSize.rows, cwd });
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
- this.pty.kill();
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
- if (client.socket.isOpen()) {
373
- client.socket.send(message);
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, cwd?: string) {
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 }, cwd?: string) {
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 room = new Room(roomId, this.ptyFactory, initialSize, cwd ?? this.cwd);
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
- room.kill();
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
  });