hop2 0.8.0 → 0.9.1

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.
@@ -21,6 +21,18 @@ export type ClientInfo = {
21
21
  rows: number;
22
22
  };
23
23
 
24
+ export type RoomCreateOptions = {
25
+ cwd: string;
26
+ env?: Record<string, string>;
27
+ shell?: string;
28
+ };
29
+
30
+ export type RoomSummary = {
31
+ id: string;
32
+ cwd: string;
33
+ clientCount: number;
34
+ };
35
+
24
36
  const MAX_BUFFER_SIZE = 200_000;
25
37
  // Keep rooms alive indefinitely; only explicit kill/remove should end a session.
26
38
  const CLEANUP_DELAY_MS = 0;
@@ -67,6 +79,7 @@ class ClientState {
67
79
  export class Room extends EventEmitter {
68
80
  id: string;
69
81
  private pty: IPty;
82
+ private readonly cwd: string;
70
83
  private clients = new Map<string, ClientState>();
71
84
  private collabMode = true;
72
85
  private controllerId: string | null = null;
@@ -80,10 +93,17 @@ export class Room extends EventEmitter {
80
93
  private cleanupTimer: NodeJS.Timeout | null = null;
81
94
  private ended = false;
82
95
 
83
- constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, cwd?: string) {
96
+ constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
84
97
  super();
85
98
  this.id = id;
86
- this.pty = ptyFactory({ cols: initialSize.cols, rows: initialSize.rows, cwd });
99
+ this.cwd = options.cwd;
100
+ this.pty = ptyFactory({
101
+ cols: initialSize.cols,
102
+ rows: initialSize.rows,
103
+ cwd: options.cwd,
104
+ env: options.env,
105
+ shell: options.shell
106
+ });
87
107
  this.activeCols = initialSize.cols;
88
108
  this.activeRows = initialSize.rows;
89
109
 
@@ -91,6 +111,7 @@ export class Room extends EventEmitter {
91
111
  this.outputBuffer = clampBuffer(this.outputBuffer + data);
92
112
  this.updateTerminalState(data);
93
113
  this.broadcast({ type: "output", data });
114
+ this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
94
115
  });
95
116
 
96
117
  const handleExit = (exitCode?: number | null, signal?: number | string) => {
@@ -243,17 +264,29 @@ export class Room extends EventEmitter {
243
264
  }
244
265
  }
245
266
 
267
+ let stateChanged = false;
246
268
  if (nextAlternate !== this.alternateScreen) {
247
269
  if (DEBUG_STATE) {
248
270
  console.log(`[hay] room=${this.id} alternateScreen=${nextAlternate}`);
249
271
  }
250
272
  this.alternateScreen = nextAlternate;
273
+ stateChanged = true;
251
274
  }
252
275
  if (nextCursorHidden !== this.cursorHidden) {
253
276
  if (DEBUG_STATE) {
254
277
  console.log(`[hay] room=${this.id} cursorHidden=${nextCursorHidden}`);
255
278
  }
256
279
  this.cursorHidden = nextCursorHidden;
280
+ stateChanged = true;
281
+ }
282
+
283
+ if (stateChanged) {
284
+ this.emit("pty_state", {
285
+ roomId: this.id,
286
+ alternateScreen: this.alternateScreen,
287
+ cursorHidden: this.cursorHidden,
288
+ timestamp: now()
289
+ });
257
290
  }
258
291
 
259
292
  this.controlSequenceTail = combined.slice(-CONTROL_SEQUENCE_TAIL);
@@ -264,10 +297,31 @@ export class Room extends EventEmitter {
264
297
  clearTimeout(this.cleanupTimer);
265
298
  this.cleanupTimer = null;
266
299
  }
267
- this.pty.kill();
300
+ try {
301
+ this.pty.kill();
302
+ } catch (e) {
303
+ /* swallow – PTY may already be dead */
304
+ }
268
305
  this.endSession({ exitCode: null, signal: null, message: "Session terminated" });
269
306
  }
270
307
 
308
+ notifySessionRenamed(displayName: string) {
309
+ this.broadcast({ type: "session_renamed", displayName });
310
+ }
311
+
312
+ sendSystemInput(data: string, source = "system") {
313
+ if (!data) return;
314
+ this.pty.write(data);
315
+ this.emit("pty_input", {
316
+ roomId: this.id,
317
+ clientId: null,
318
+ actor: "system",
319
+ source,
320
+ data,
321
+ timestamp: now()
322
+ });
323
+ }
324
+
271
325
  private handleInput(client: ClientState, data: string) {
272
326
  if (!this.collabMode && this.controllerId !== client.id) {
273
327
  client.socket.send(
@@ -277,6 +331,7 @@ export class Room extends EventEmitter {
277
331
  }
278
332
  client.lastActive = now();
279
333
  this.pty.write(data);
334
+ this.emit("pty_input", { roomId: this.id, clientId: client.id, data, timestamp: now() });
280
335
  }
281
336
 
282
337
  private handleResize(client: ClientState, cols: number, rows: number) {
@@ -297,6 +352,7 @@ export class Room extends EventEmitter {
297
352
  this.activeClientId = client.id;
298
353
  // Broadcast the new active size to other clients so they can adjust
299
354
  this.broadcastActiveSize(client);
355
+ this.emit("pty_resize", { roomId: this.id, clientId: client.id, cols, rows, timestamp: now() });
300
356
  }
301
357
  }
302
358
 
@@ -369,32 +425,49 @@ export class Room extends EventEmitter {
369
425
  }
370
426
  const message = JSON.stringify({ type: "session_ended", ...payload } satisfies ServerMessage);
371
427
  for (const client of this.clients.values()) {
372
- if (client.socket.isOpen()) {
373
- client.socket.send(message);
428
+ try {
429
+ if (client.socket.isOpen()) {
430
+ client.socket.send(message);
431
+ }
432
+ client.socket.close();
433
+ } catch (e) {
434
+ /* swallow – don't let one bad socket kill the loop */
374
435
  }
375
- client.socket.close();
376
436
  }
377
437
  this.clients.clear();
438
+ this.emit("session_end", { roomId: this.id, ...payload, timestamp: now() });
378
439
  this.emit("empty");
379
440
  }
441
+
442
+ getSummary(): RoomSummary {
443
+ return {
444
+ id: this.id,
445
+ cwd: this.cwd,
446
+ clientCount: this.clients.size
447
+ };
448
+ }
380
449
  }
381
450
 
382
451
  export class RoomManager {
383
452
  private rooms = new Map<string, Room>();
384
453
  private ptyFactory: PtyFactory;
385
- private cwd?: string;
386
454
 
387
- constructor(ptyFactory: PtyFactory, cwd?: string) {
455
+ constructor(ptyFactory: PtyFactory) {
388
456
  this.ptyFactory = ptyFactory;
389
- this.cwd = cwd;
390
457
  }
391
458
 
392
- getRoom(roomId: string, initialSize: { cols: number; rows: number }, cwd?: string) {
459
+ getRoom(roomId: string, initialSize: { cols: number; rows: number }, cwdOrOptions: string | RoomCreateOptions) {
393
460
  const existing = this.rooms.get(roomId);
394
461
  if (existing) {
395
462
  return existing;
396
463
  }
397
- const room = new Room(roomId, this.ptyFactory, initialSize, cwd ?? this.cwd);
464
+ const options = typeof cwdOrOptions === "string"
465
+ ? { cwd: cwdOrOptions }
466
+ : cwdOrOptions;
467
+ if (!options.cwd) {
468
+ throw new Error(`[RoomManager] cwd is required when creating room "${roomId}"`);
469
+ }
470
+ const room = new Room(roomId, this.ptyFactory, initialSize, options);
398
471
  room.on("empty", () => {
399
472
  this.rooms.delete(roomId);
400
473
  });
@@ -409,7 +482,11 @@ export class RoomManager {
409
482
  closeRoom(roomId: string) {
410
483
  const room = this.rooms.get(roomId);
411
484
  if (!room) return;
412
- room.kill();
485
+ try {
486
+ room.kill();
487
+ } catch (e) {
488
+ /* swallow – ensure we still remove the room from the map */
489
+ }
413
490
  this.rooms.delete(roomId);
414
491
  }
415
492
 
@@ -418,4 +495,8 @@ export class RoomManager {
418
495
  this.closeRoom(roomId);
419
496
  }
420
497
  }
498
+
499
+ listRooms(): RoomSummary[] {
500
+ return Array.from(this.rooms.values()).map((room) => room.getSummary());
501
+ }
421
502
  }
@@ -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
  });