hop2 0.11.11 → 0.11.13

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.
@@ -243,50 +243,85 @@ describe("Room", () => {
243
243
  expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 90, rows: 20 });
244
244
  });
245
245
 
246
- it('claim:"attach" takes the size unless a peer typed seconds ago', () => {
247
- vi.useFakeTimers();
248
- try {
249
- let ptyInstance: FakePty | null = null;
250
- const factory: PtyFactory = () => {
251
- ptyInstance = new FakePty() as unknown as FakePty;
252
- return ptyInstance as any;
253
- };
254
- const manager = new RoomManager(factory);
255
- const room = manager.getRoom("attach-claim", { cols: 80, rows: 24 }, "/tmp");
256
- const cli = new FakeSocket();
257
- room.attachClient({ id: "cli", name: "Local", colorIndex: 0, cols: 80, rows: 24 }, cli);
258
-
259
- // The desktop CLI is actively typing right now.
260
- cli.emitMessage({ type: "input", data: "ls" });
261
-
262
- // A phone opens the session 2s later and attach-claims its fitted size:
263
- // an actively-typing peer keeps the size — the claim loses.
264
- vi.advanceTimersByTime(2_000);
265
- const phone = new FakeSocket();
266
- room.attachClient({ id: "ph", name: "Phone", colorIndex: 1, cols: 46, rows: 28 }, phone);
267
- const before = ptyInstance!.resizes.length;
268
- phone.emitMessage({ type: "resize", cols: 46, rows: 28, claim: "attach" });
269
- expect(ptyInstance!.resizes.length).toBe(before);
270
-
271
- // 10s after the CLI's last keystroke (well inside the 60s a plain
272
- // resize would need), the attach claim wins: opening a session is
273
- // intent, and nobody is typing anymore.
274
- vi.advanceTimersByTime(10_000);
275
- phone.emitMessage({ type: "resize", cols: 46, rows: 28, claim: "attach" });
276
- expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 46, rows: 28 });
277
-
278
- // A plain (unclaimed) resize from another fresh viewer still cannot
279
- // yank the size at 10s idle — the 60s election guards those.
280
- const viewer = new FakeSocket();
281
- room.attachClient({ id: "v", name: "Viewer", colorIndex: 2, cols: 200, rows: 50 }, viewer);
282
- phone.emitMessage({ type: "input", data: "x" });
283
- vi.advanceTimersByTime(10_000);
284
- const beforeViewer = ptyInstance!.resizes.length;
285
- viewer.emitMessage({ type: "resize", cols: 200, rows: 50 });
286
- expect(ptyInstance!.resizes.length).toBe(beforeViewer);
287
- } finally {
288
- vi.useRealTimers();
289
- }
246
+ it("lets a monitor size only an unattended session", () => {
247
+ let ptyInstance: FakePty | null = null;
248
+ const factory: PtyFactory = () => {
249
+ ptyInstance = new FakePty() as unknown as FakePty;
250
+ return ptyInstance as any;
251
+ };
252
+
253
+ const manager = new RoomManager(factory);
254
+ const room = manager.getRoom("monitor-resize", { cols: 80, rows: 24 }, "/tmp");
255
+ const desktop = new FakeSocket();
256
+ const monitor = new FakeSocket();
257
+ room.attachClient(
258
+ { id: "desktop", name: "Desktop", colorIndex: 0, cols: 80, rows: 24, nudge: false },
259
+ desktop
260
+ );
261
+ room.attachClient(
262
+ { id: "wall", name: "Wall", source: "monitor", colorIndex: 1, cols: 80, rows: 40, nudge: false },
263
+ monitor
264
+ );
265
+
266
+ const before = ptyInstance!.resizes.length;
267
+ monitor.emitMessage({ type: "resize", cols: 80, rows: 40, claim: "attach", user: true });
268
+ expect(ptyInstance!.resizes.length).toBe(before);
269
+ expect(findMessages(monitor, "active_size").at(-1)).toMatchObject({ cols: 80, rows: 24 });
270
+
271
+ desktop.close();
272
+ monitor.emitMessage({ type: "resize", cols: 80, rows: 40, claim: "attach", user: true });
273
+ expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 80, rows: 40 });
274
+ });
275
+
276
+ it("the last user action — open or keystroke — owns the size", () => {
277
+ let ptyInstance: FakePty | null = null;
278
+ const factory: PtyFactory = () => {
279
+ ptyInstance = new FakePty() as unknown as FakePty;
280
+ return ptyInstance as any;
281
+ };
282
+ const manager = new RoomManager(factory);
283
+ const room = manager.getRoom("attach-claim", { cols: 80, rows: 24 }, "/tmp");
284
+ const cli = new FakeSocket();
285
+ room.attachClient({ id: "cli", name: "Local", colorIndex: 0, cols: 80, rows: 24 }, cli);
286
+
287
+ // The desktop CLI is actively typing right now.
288
+ cli.emitMessage({ type: "input", data: "ls" });
289
+
290
+ // A phone opens the session and attach-claims its fitted size: opening
291
+ // is the latest user action, so the claim wins outright — even over
292
+ // someone who typed a moment ago. Strict recency, no idle windows.
293
+ const phone = new FakeSocket();
294
+ room.attachClient({ id: "ph", name: "Phone", colorIndex: 1, cols: 46, rows: 28 }, phone);
295
+ phone.emitMessage({ type: "resize", cols: 46, rows: 28, claim: "attach" });
296
+ expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 46, rows: 28 });
297
+
298
+ // The CLI's next keystroke is now the latest user action: ownership
299
+ // transfers back and the PTY snaps to the CLI's declared fit
300
+ // immediately — before any resize message from it.
301
+ cli.emitMessage({ type: "input", data: "x" });
302
+ expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 80, rows: 24 });
303
+
304
+ // A plain (unclaimed) resize from a fresh viewer is a declaration, not
305
+ // a user action — it cannot move an owned size, and the viewer is
306
+ // snapped back to the shared grid.
307
+ const viewer = new FakeSocket();
308
+ room.attachClient({ id: "v", name: "Viewer", colorIndex: 2, cols: 200, rows: 50 }, viewer);
309
+ const beforeViewer = ptyInstance!.resizes.length;
310
+ viewer.emitMessage({ type: "resize", cols: 200, rows: 50 });
311
+ expect(ptyInstance!.resizes.length).toBe(beforeViewer);
312
+ expect(findMessages(viewer, "active_size").at(-1)).toMatchObject({ cols: 80, rows: 24, clientId: "cli" });
313
+
314
+ // But that declaration is remembered: the viewer's first keystroke makes
315
+ // it the owner and the PTY takes its declared 200×50 in the same beat.
316
+ viewer.emitMessage({ type: "input", data: "y" });
317
+ expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 200, rows: 50 });
318
+
319
+ // Wheel reports are reading, not typing: a pure SGR mouse payload from
320
+ // the phone transfers nothing.
321
+ const beforeWheel = ptyInstance!.resizes.length;
322
+ phone.emitMessage({ type: "input", data: "\x1b[<64;40;12M\x1b[<65;40;12M" });
323
+ phone.emitMessage({ type: "resize", cols: 46, rows: 28 });
324
+ expect(ptyInstance!.resizes.length).toBe(beforeWheel);
290
325
  });
291
326
 
292
327
  it("bounds the join-time snapshot replay to a tail cut at a line boundary", () => {
@@ -408,41 +443,31 @@ describe("Room", () => {
408
443
  expect(last?.clientId).toBe("typer");
409
444
  });
410
445
 
411
- it("lets a resize claim the size once every other client is input-idle", () => {
412
- vi.useFakeTimers();
413
- try {
414
- vi.setSystemTime(1_000_000);
415
- let ptyInstance: FakePty | null = null;
416
- const factory: PtyFactory = () => {
417
- ptyInstance = new FakePty() as unknown as FakePty;
418
- return ptyInstance as any;
419
- };
420
- const manager = new RoomManager(factory);
421
- const room = manager.getRoom("claim", { cols: 120, rows: 30 }, "/tmp");
422
- const typer = new FakeSocket();
423
- const viewer = new FakeSocket();
424
- room.attachClient({ id: "typer", name: "Alex", colorIndex: 0, cols: 120, rows: 30 }, typer);
425
- room.attachClient({ id: "viewer", name: "Blake", colorIndex: 1, cols: 60, rows: 20 }, viewer);
426
-
427
- typer.emitMessage({ type: "input", data: "ls\r" });
428
- typer.emitMessage({ type: "resize", cols: 120, rows: 30 });
429
-
430
- // Recently-typed elsewhere: the viewer's autofit is refused (and snapped back).
431
- viewer.emitMessage({ type: "resize", cols: 60, rows: 20 });
432
- expect((ptyInstance as unknown as FakePty).resizes.at(-1)).toEqual({ cols: 120, rows: 30 });
433
-
434
- // Everyone else idle past the claim window: the same autofit now wins.
435
- vi.setSystemTime(1_000_000 + 61_000);
436
- viewer.emitMessage({ type: "resize", cols: 60, rows: 20 });
437
- expect((ptyInstance as unknown as FakePty).resizes.at(-1)).toEqual({ cols: 60, rows: 20 });
438
-
439
- // The previous holder is told the size moved.
440
- const sizes = findMessages(typer, "active_size");
441
- expect(sizes.at(-1)?.cols).toBe(60);
442
- expect(sizes.at(-1)?.clientId).toBe("viewer");
443
- } finally {
444
- vi.useRealTimers();
445
- }
446
+ it("frees the size when the owner detaches — no idle window needed", () => {
447
+ let ptyInstance: FakePty | null = null;
448
+ const factory: PtyFactory = () => {
449
+ ptyInstance = new FakePty() as unknown as FakePty;
450
+ return ptyInstance as any;
451
+ };
452
+ const manager = new RoomManager(factory);
453
+ const room = manager.getRoom("claim", { cols: 120, rows: 30 }, "/tmp");
454
+ const typer = new FakeSocket();
455
+ const viewer = new FakeSocket();
456
+ room.attachClient({ id: "typer", name: "Alex", colorIndex: 0, cols: 120, rows: 30 }, typer);
457
+ room.attachClient({ id: "viewer", name: "Blake", colorIndex: 1, cols: 60, rows: 20 }, viewer);
458
+
459
+ typer.emitMessage({ type: "input", data: "ls\r" });
460
+ typer.emitMessage({ type: "resize", cols: 120, rows: 30 });
461
+
462
+ // The owner is elsewhere: the viewer's autofit is refused (and snapped back).
463
+ viewer.emitMessage({ type: "resize", cols: 60, rows: 20 });
464
+ expect((ptyInstance as unknown as FakePty).resizes.at(-1)).toEqual({ cols: 120, rows: 30 });
465
+
466
+ // The owner leaves: the size is up for grabs, and the viewer's next
467
+ // plain refit takes it — no keystroke, no claim, no waiting.
468
+ typer.close();
469
+ viewer.emitMessage({ type: "resize", cols: 60, rows: 20 });
470
+ expect((ptyInstance as unknown as FakePty).resizes.at(-1)).toEqual({ cols: 60, rows: 20 });
446
471
  });
447
472
 
448
473
  it("counts attention bells but not OSC-terminator BELs", () => {
@@ -813,6 +838,31 @@ describe("Room", () => {
813
838
  }
814
839
  });
815
840
 
841
+ it("preserves consecutive shrink and grow transitions in the screen grid", async () => {
842
+ const { room, pty } = await setup("ordered-grid-resize-burst", { cols: 20, rows: 5 });
843
+ const owner = new FakeSocket();
844
+ room.attachClient(
845
+ { id: "owner", name: "Owner", colorIndex: 0, cols: 20, rows: 5, replayBytes: 0, nudge: false },
846
+ owner
847
+ );
848
+ const rows = Array.from({ length: 5 }, (_, i) => `\x1b[${i + 1};1HROW-${i + 1}`).join("");
849
+ pty().emit("\x1b[?1049h\x1b[2J" + rows);
850
+
851
+ // Even with no output between them, shrink then grow is not equivalent
852
+ // to only the final size: xterm discards the rows clipped by the shrink.
853
+ owner.emitMessage({ type: "resize", cols: 20, rows: 2, user: true });
854
+ owner.emitMessage({ type: "resize", cols: 20, rows: 5, user: true });
855
+ const late = new FakeSocket();
856
+ room.attachClient(
857
+ { id: "late", name: "Late", colorIndex: 1, cols: 20, rows: 5, replayBytes: 65536 },
858
+ late
859
+ );
860
+ const snapshot = await waitForSnapshot(late);
861
+ expect(String(snapshot.data)).not.toContain("ROW-1");
862
+ expect(String(snapshot.data)).toContain("ROW-4");
863
+ expect(String(snapshot.data)).toContain("ROW-5");
864
+ });
865
+
816
866
  it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
817
867
  const { room, pty } = await setup("capped");
818
868
  const big = "\x1b[20;1H" + "y".repeat(4096);
@@ -954,3 +1004,57 @@ describe("Room", () => {
954
1004
  });
955
1005
  });
956
1006
  });
1007
+
1008
+ describe("device-key eviction", () => {
1009
+ // The room actively closes the predecessor's socket; a plain FakeSocket
1010
+ // close is indistinguishable from a client-side close, so count them.
1011
+ class TrackingSocket extends FakeSocket {
1012
+ serverCloses = 0;
1013
+ close() {
1014
+ this.serverCloses += 1;
1015
+ super.close();
1016
+ }
1017
+ }
1018
+
1019
+ const makeRoom = (name: string) => {
1020
+ const manager = new RoomManager(() => new FakePty() as any);
1021
+ return manager.getRoom(name, { cols: 80, rows: 24 }, "/tmp");
1022
+ };
1023
+
1024
+ it("a same-key attach replaces the predecessor instead of joining it", () => {
1025
+ const room = makeRoom("evict");
1026
+ const ghost = new TrackingSocket();
1027
+ const fresh = new FakeSocket();
1028
+ room.attachClient({ id: "a", name: "iPhone", colorIndex: 0, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, ghost);
1029
+ room.attachClient({ id: "b", name: "iPhone", colorIndex: 1, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, fresh);
1030
+
1031
+ expect(ghost.serverCloses).toBe(1);
1032
+ const presence = findMessages(fresh, "presence").at(-1) as any;
1033
+ expect(presence.clients.map((c: { id: string }) => c.id)).toEqual(["b"]);
1034
+ });
1035
+
1036
+ it("different tabs from the same device coexist", () => {
1037
+ const room = makeRoom("tabs");
1038
+ const tabA = new TrackingSocket();
1039
+ const tabB = new FakeSocket();
1040
+ room.attachClient({ id: "a", name: "iPhone", colorIndex: 0, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, tabA);
1041
+ room.attachClient({ id: "b", name: "iPhone", colorIndex: 1, cols: 80, rows: 24, deviceKey: "dev1.tab2" }, tabB);
1042
+
1043
+ expect(tabA.serverCloses).toBe(0);
1044
+ const presence = findMessages(tabB, "presence").at(-1) as any;
1045
+ expect(presence.clients.map((c: { id: string }) => c.id).sort()).toEqual(["a", "b"]);
1046
+ });
1047
+
1048
+ it("keyless clients (CLI, older bundles) are never evicted", () => {
1049
+ const room = makeRoom("legacy");
1050
+ const legacy = new TrackingSocket();
1051
+ const keyed = new FakeSocket();
1052
+ room.attachClient({ id: "a", name: "cli", colorIndex: 0, cols: 80, rows: 24 }, legacy);
1053
+ room.attachClient({ id: "b", name: "iPhone", colorIndex: 1, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, keyed);
1054
+ room.attachClient({ id: "c", name: "iPhone", colorIndex: 2, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, new FakeSocket());
1055
+
1056
+ expect(legacy.serverCloses).toBe(0);
1057
+ const presence = findMessages(legacy, "presence").at(-1) as any;
1058
+ expect(presence.clients.map((c: { id: string }) => c.id).sort()).toEqual(["a", "c"]);
1059
+ });
1060
+ });
@@ -113,6 +113,46 @@ const runE2E = async () => {
113
113
  const switchedBuffer = await page1.evaluate(() => window.__hay?.getBufferText() || "");
114
114
  assert.ok(!switchedBuffer.includes("shared"), `old room output survived session switch: ${JSON.stringify(switchedBuffer)}`);
115
115
 
116
+ // A wall/watch tile may fit an unattended room, but it must not replace
117
+ // the PTY geometry underneath an attached desktop. Claude's alternate
118
+ // screen has no local scrollback, so foreign rows can otherwise be clipped.
119
+ const monitorClaim = await page1.evaluate(async (roomName) => {
120
+ const peer = new WebSocket(
121
+ `ws://${location.host}/ws?room=${encodeURIComponent(roomName)}&name=Wall&source=monitor&replay=0&cols=240&rows=70`
122
+ );
123
+ const nextActiveSize = () => new Promise((resolve) => {
124
+ const handler = (event) => {
125
+ const message = JSON.parse(String(event.data));
126
+ if (message.type !== "active_size") return;
127
+ peer.removeEventListener("message", handler);
128
+ resolve(message);
129
+ };
130
+ peer.addEventListener("message", handler);
131
+ });
132
+ const initialSize = nextActiveSize();
133
+ await new Promise((resolve, reject) => {
134
+ peer.onopen = resolve;
135
+ peer.onerror = reject;
136
+ });
137
+ const before = await initialSize;
138
+ const claimedSize = nextActiveSize();
139
+ peer.send(JSON.stringify({
140
+ type: "resize",
141
+ cols: 240,
142
+ rows: 70,
143
+ claim: "attach",
144
+ user: true
145
+ }));
146
+ const after = await claimedSize;
147
+ peer.close();
148
+ return { before, after };
149
+ }, freshRoom);
150
+ assert.deepEqual(
151
+ { cols: monitorClaim.after.cols, rows: monitorClaim.after.rows },
152
+ { cols: monitorClaim.before.cols, rows: monitorClaim.before.rows },
153
+ `monitor replaced an attached desktop's PTY size: ${JSON.stringify(monitorClaim)}`
154
+ );
155
+
116
156
  // A resize can collapse scrollback to y=base=0 without xterm emitting an
117
157
  // onScroll event. App follow-mode must reconcile that state before the
118
158
  // next real room output grows below a viewport parked at 0.