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.
- package/hay/CHANGELOG.md +2 -1
- package/hay/apps/server/src/index.ts +2 -0
- package/hay/apps/server/src/lib.ts +2 -0
- package/hay/apps/server/src/rooms.ts +127 -76
- package/hay/apps/server/src/screenGrid.ts +30 -2
- package/hay/apps/server/test/rooms.test.ts +183 -79
- package/hay/apps/web/scripts/run-e2e.mjs +40 -0
- package/hay/apps/web/src/App.tsx +231 -97
- package/hay/apps/web/src/components/SessionSwitcher.tsx +158 -17
- package/hay/apps/web/src/styles.css +32 -0
- package/hay/apps/web/src/utils/switcherModel.ts +52 -13
- package/hay/apps/web/test/SessionSwitcher.test.tsx +12 -0
- package/hay/apps/web/test/switcherModel.test.ts +61 -0
- package/hay-web/assets/digest-archive.json +604 -0
- package/hay-web/assets/index--8sYRyoP.js +133 -0
- package/hay-web/assets/index--8sYRyoP.js.map +1 -0
- package/hay-web/assets/index-DHotLq-D.css +1 -0
- package/hay-web/assets/index-DnSA-VR6.js +133 -0
- package/hay-web/assets/{index-DQ3VWtnf.js.map → index-DnSA-VR6.js.map} +1 -1
- package/hay-web/assets/{index-DQ3VWtnf.js → index-DzlPn2In.js} +16 -16
- package/hay-web/assets/index-DzlPn2In.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +607 -109
- package/package.json +1 -1
- package/scripts/hay-host.js +25 -0
- package/skills/hop-session/SKILL.md +29 -2
- package/tests/terminal-api.integration.test.cjs +52 -2
|
@@ -243,50 +243,85 @@ describe("Room", () => {
|
|
|
243
243
|
expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 90, rows: 20 });
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
-
it(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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("
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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.
|