hop2 0.11.3 → 0.11.5
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/apps/server/package.json +3 -1
- package/hay/apps/server/src/rooms.ts +142 -33
- package/hay/apps/server/src/screenGrid.ts +145 -0
- package/hay/apps/server/test/rooms.test.ts +104 -0
- package/hay/apps/web/src/App.tsx +13 -4
- package/hay/apps/web/src/components/SessionSwitcher.tsx +266 -16
- package/hay/apps/web/src/styles.css +39 -4
- package/hay/apps/web/src/utils/scrollFlywheel.ts +140 -14
- package/hay/apps/web/src/utils/voiceHold.ts +13 -0
- package/hay/apps/web/test/claudeSurface.test.ts +44 -0
- package/hay/package-lock.json +17 -0
- package/hay/packages/shared/src/protocol.ts +6 -1
- package/hay-web/assets/{index-BRpBf8QE.css → index-BJnLaQd5.css} +1 -1
- package/hay-web/assets/index-CJHvzXqn.js +133 -0
- package/hay-web/assets/{index-BrLbmVOB.js.map → index-CJHvzXqn.js.map} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +392 -147
- package/hopa +6 -1
- package/lib/hay-host-watchdog.js +97 -0
- package/mcp/hop-mcp.js +6 -1
- package/package.json +1 -1
- package/scripts/hay-host.js +3 -1
- package/scripts/salvage-and-restart-host.js +116 -0
- package/tests/hay-host-watchdog.unit.test.cjs +109 -0
- package/tests/terminal-api.integration.test.cjs +74 -0
- package/hay-web/assets/index-BrLbmVOB.js +0 -132
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
"test": "vitest run"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"
|
|
23
|
+
"@xterm/addon-serialize": "^0.14.0",
|
|
24
|
+
"@xterm/headless": "^6.0.0",
|
|
24
25
|
"express": "^4.21.2",
|
|
26
|
+
"hay-shared": "*",
|
|
25
27
|
"node-pty-prebuilt-multiarch": "^0.10.1-pre.5",
|
|
26
28
|
"ws": "^8.18.0"
|
|
27
29
|
},
|
|
@@ -5,6 +5,8 @@ import type { IPty } from "node-pty-prebuilt-multiarch";
|
|
|
5
5
|
import { clientMessageSchema, pickPresenceColor, safeParseClientMessage } from "hay-shared";
|
|
6
6
|
import type { ClientMessage, PresenceClient, ServerMessage } from "hay-shared";
|
|
7
7
|
import type { PtyFactory } from "./pty";
|
|
8
|
+
import { createScreenGrid, loadScreenGridDeps, screenGridsAvailable } from "./screenGrid";
|
|
9
|
+
import type { ScreenGrid } from "./screenGrid";
|
|
8
10
|
|
|
9
11
|
export type SocketAdapter = {
|
|
10
12
|
send: (data: string) => void;
|
|
@@ -26,7 +28,11 @@ export type ClientInfo = {
|
|
|
26
28
|
cols: number;
|
|
27
29
|
rows: number;
|
|
28
30
|
// Optional per-connection cap for the join snapshot (clamped to the
|
|
29
|
-
// room-level bound).
|
|
31
|
+
// room-level bound). 0 = no snapshot at all (live stream only — size-claim
|
|
32
|
+
// and kick-start sockets). Bounded requests (tiles ~64KB, web full screen
|
|
33
|
+
// ~384KB) are served a SERIALIZED grid — complete and a few KB — while
|
|
34
|
+
// larger requests are a deliberate ask for scrollback DEPTH and keep the
|
|
35
|
+
// raw-tail replay. Unset = raw tail at the room default (CLI reattach).
|
|
30
36
|
replayBytes?: number;
|
|
31
37
|
// False = skip the equal-size attach "wiggle" (the −1/+1 column repaint
|
|
32
38
|
// nudge). Wall tiles decline it: they arrive already showing the current
|
|
@@ -96,6 +102,21 @@ const boundSnapshotReplay = (buffer: string): string => {
|
|
|
96
102
|
return tail;
|
|
97
103
|
};
|
|
98
104
|
|
|
105
|
+
// Serialized attach: connections asking for a bounded snapshot (web full
|
|
106
|
+
// screen, wall tiles) get the room grid serialized — complete and a few KB —
|
|
107
|
+
// instead of a raw tail. Larger requests are a deliberate ask for DEPTH
|
|
108
|
+
// (deep-history reload, CLI full-scrollback reattach): raw tail reconstructs
|
|
109
|
+
// far more scrollback than the grid retains, so those keep the old path.
|
|
110
|
+
// Kill switch: HAY_SERIALIZED_ATTACH=0 reverts every attach to raw tails.
|
|
111
|
+
const SERIALIZED_ATTACH_ENABLED = process.env.HAY_SERIALIZED_ATTACH !== "0";
|
|
112
|
+
const SERIALIZED_MAX_REQUEST = 524_288;
|
|
113
|
+
// Above this much retained raw history, the serialized snapshot is probably
|
|
114
|
+
// shallower than what a deep replay could reconstruct — tell the client, so
|
|
115
|
+
// its scroll-to-top full-history reload still knows to offer itself (it used
|
|
116
|
+
// to infer cappedness from raw snapshot SIZE, which a few-KB serialized
|
|
117
|
+
// snapshot never trips).
|
|
118
|
+
const SERIALIZED_CAPPED_HINT_BYTES = 262_144;
|
|
119
|
+
|
|
99
120
|
// Autofit-on-attach: a resize may claim the shared size when every OTHER
|
|
100
121
|
// client has been input-idle at least this long. Someone actively typing keeps
|
|
101
122
|
// the size they set; a viewer opening the session after everyone went idle
|
|
@@ -235,6 +256,11 @@ export class Room extends EventEmitter {
|
|
|
235
256
|
// False until the first client attaches: that client is the one whose
|
|
236
257
|
// connect created the room, so its hello carries created=true.
|
|
237
258
|
private hasHadClient = false;
|
|
259
|
+
// Authoritative screen state for serialized attach (see screenGrid.ts).
|
|
260
|
+
// Created lazily once the headless deps load; hydrated from the retained
|
|
261
|
+
// ring at creation so bytes that arrived earlier are never missing.
|
|
262
|
+
private grid: ScreenGrid | null = null;
|
|
263
|
+
private gridHydrated = false;
|
|
238
264
|
|
|
239
265
|
constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
|
|
240
266
|
super();
|
|
@@ -354,45 +380,92 @@ export class Room extends EventEmitter {
|
|
|
354
380
|
} satisfies ServerMessage)
|
|
355
381
|
);
|
|
356
382
|
|
|
357
|
-
|
|
383
|
+
const replayRequest = Number.isFinite(info.replayBytes) ? Math.floor(info.replayBytes as number) : null;
|
|
384
|
+
const snapshotFlags = () => ({
|
|
385
|
+
alternateScreen: this.alternateScreen,
|
|
386
|
+
cursorHidden: this.cursorHidden,
|
|
387
|
+
keyboardEnhanced: this.keyboardEnhanced,
|
|
388
|
+
mouseReporting: this.mouseReporting,
|
|
389
|
+
mouseSgr: this.mouseSgr
|
|
390
|
+
});
|
|
391
|
+
const sendRawSnapshot = () => {
|
|
358
392
|
socket.send(JSON.stringify({
|
|
359
393
|
type: "snapshot",
|
|
360
394
|
data: boundSnapshotReplay(this.tailOutput(
|
|
361
|
-
|
|
362
|
-
? Math.min(SNAPSHOT_REPLAY_BYTES,
|
|
395
|
+
replayRequest !== null && replayRequest > 0
|
|
396
|
+
? Math.min(SNAPSHOT_REPLAY_BYTES, replayRequest)
|
|
363
397
|
: SNAPSHOT_REPLAY_BYTES
|
|
364
398
|
)),
|
|
365
|
-
|
|
366
|
-
cursorHidden: this.cursorHidden,
|
|
367
|
-
keyboardEnhanced: this.keyboardEnhanced,
|
|
368
|
-
mouseReporting: this.mouseReporting,
|
|
369
|
-
mouseSgr: this.mouseSgr
|
|
399
|
+
...snapshotFlags()
|
|
370
400
|
} satisfies ServerMessage));
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
401
|
+
};
|
|
402
|
+
// A raw TAIL can be missing rows incremental TUIs (Claude Code/Ink) never
|
|
403
|
+
// re-emitted within the window. When this client's size differs from the
|
|
404
|
+
// PTY's, the attach resize triggers a full repaint anyway; when sizes are
|
|
405
|
+
// EQUAL nothing would repaint, so nudge: a one-column wiggle (tmux's
|
|
406
|
+
// redraw trick) makes full-screen apps repaint. Raw-path compensation
|
|
407
|
+
// only — a serialized attach is already complete.
|
|
408
|
+
const wiggle = () => {
|
|
409
|
+
if (
|
|
410
|
+
info.nudge !== false &&
|
|
411
|
+
client.cols === this.activeCols &&
|
|
412
|
+
client.rows === this.activeRows &&
|
|
413
|
+
this.activeCols > 4 &&
|
|
414
|
+
!this.ended
|
|
415
|
+
) {
|
|
416
|
+
const cols = this.activeCols;
|
|
417
|
+
const rows = this.activeRows;
|
|
418
|
+
try { this.pty.resize(cols - 1, rows); } catch { /* pty may be gone */ }
|
|
419
|
+
setTimeout(() => {
|
|
420
|
+
if (this.ended) return;
|
|
421
|
+
// Only bounce back if nobody claimed a different size meanwhile.
|
|
422
|
+
if (this.activeCols === cols && this.activeRows === rows) {
|
|
423
|
+
try { this.pty.resize(cols, rows); } catch { /* pty may be gone */ }
|
|
424
|
+
}
|
|
425
|
+
}, 60);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
// replay=0 is an explicit "live stream only" (size-claim and kick-start
|
|
429
|
+
// sockets): honor it — these connections used to fall through to the
|
|
430
|
+
// DEFAULT cap and silently pull the full 1.5MB tail they never read.
|
|
431
|
+
const wantsSnapshot = this.outputBytes > 0 && replayRequest !== 0;
|
|
432
|
+
const gridForAttach = wantsSnapshot && replayRequest !== null && replayRequest > 0
|
|
433
|
+
&& replayRequest <= SERIALIZED_MAX_REQUEST
|
|
434
|
+
? this.ensureGrid()
|
|
435
|
+
: null;
|
|
436
|
+
// Scrollback rides along in proportion to the ask: a tile's ~64KB request
|
|
437
|
+
// wants the screen (plus enough history for a casual scroll-up), while
|
|
438
|
+
// the web full screen's ~384KB request wants the grid's full depth.
|
|
439
|
+
const serializedScrollback = replayRequest !== null && replayRequest <= 131_072 ? 200 : undefined;
|
|
440
|
+
if (gridForAttach) {
|
|
441
|
+
gridForAttach.serialize((serialized) => {
|
|
442
|
+
if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) return;
|
|
443
|
+
if (serialized === null) {
|
|
444
|
+
sendRawSnapshot();
|
|
445
|
+
wiggle();
|
|
446
|
+
return;
|
|
394
447
|
}
|
|
395
|
-
|
|
448
|
+
// Serialization carries the SCREEN but not private modes — the raw
|
|
449
|
+
// tail incidentally carried the app's mode-enable sequences, and
|
|
450
|
+
// clients (each terminal's own mode state) depend on them to route
|
|
451
|
+
// wheel events to the app vs local scrollback and to gate selection.
|
|
452
|
+
// Re-assert them in-band so every client — including ones that never
|
|
453
|
+
// read the separate flags — ends up in the app's actual modes.
|
|
454
|
+
const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
|
|
455
|
+
+ (this.cursorHidden ? "\x1b[?25l" : "");
|
|
456
|
+
socket.send(JSON.stringify({
|
|
457
|
+
type: "snapshot",
|
|
458
|
+
// In-band RIS: every client applies the serialized screen onto a
|
|
459
|
+
// clean slate, including ones that don't reset on their own.
|
|
460
|
+
data: "\x1bc" + serialized + modeSeq,
|
|
461
|
+
capped: this.outputBytes > SERIALIZED_CAPPED_HINT_BYTES,
|
|
462
|
+
...snapshotFlags()
|
|
463
|
+
} satisfies ServerMessage));
|
|
464
|
+
// No wiggle: the serialized grid IS the current screen.
|
|
465
|
+
}, serializedScrollback);
|
|
466
|
+
} else {
|
|
467
|
+
if (wantsSnapshot) sendRawSnapshot();
|
|
468
|
+
if (replayRequest !== 0) wiggle();
|
|
396
469
|
}
|
|
397
470
|
|
|
398
471
|
// Send current cwd to the new client
|
|
@@ -802,6 +875,7 @@ export class Room extends EventEmitter {
|
|
|
802
875
|
this.pty.resize(cols, rows);
|
|
803
876
|
this.activeCols = cols;
|
|
804
877
|
this.activeRows = rows;
|
|
878
|
+
this.grid?.resize(cols, rows);
|
|
805
879
|
this.activeClientId = client.id;
|
|
806
880
|
// Broadcast the new active size to other clients so they can adjust —
|
|
807
881
|
// and CONFIRM to the winner: a claimant otherwise learns nothing on
|
|
@@ -947,6 +1021,33 @@ export class Room extends EventEmitter {
|
|
|
947
1021
|
}
|
|
948
1022
|
}
|
|
949
1023
|
|
|
1024
|
+
// Grids are LAZY: created on the first serialized attach, not on output,
|
|
1025
|
+
// so a room nobody ever opens (agent fleets) pays no grid memory at all.
|
|
1026
|
+
// Creation hydrates from a bounded ring tail — deep enough to contain any
|
|
1027
|
+
// app's last full repaint plus every update since, which is all screen
|
|
1028
|
+
// correctness needs; serialized scrollback is bounded anyway.
|
|
1029
|
+
private static readonly GRID_HYDRATE_BYTES = 2_000_000;
|
|
1030
|
+
private ensureGrid(): ScreenGrid | null {
|
|
1031
|
+
if (!SERIALIZED_ATTACH_ENABLED || this.ended) return this.grid;
|
|
1032
|
+
if (!this.grid && screenGridsAvailable()) {
|
|
1033
|
+
this.grid = createScreenGrid(this.activeCols, this.activeRows);
|
|
1034
|
+
if (this.grid && !this.gridHydrated) {
|
|
1035
|
+
this.gridHydrated = true;
|
|
1036
|
+
if (this.outputBytes > 0) {
|
|
1037
|
+
// Cut at a line boundary (same trick as boundSnapshotReplay) so a
|
|
1038
|
+
// slice landing mid-escape-sequence can't garble the parse head.
|
|
1039
|
+
let tail = this.tailOutput(Room.GRID_HYDRATE_BYTES);
|
|
1040
|
+
if (this.outputBytes > tail.length) {
|
|
1041
|
+
const nl = tail.indexOf("\n");
|
|
1042
|
+
if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
|
|
1043
|
+
}
|
|
1044
|
+
this.grid.write(tail);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return this.grid;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
950
1051
|
/** O(1) append; trims whole chunks from the head past MAX_BUFFER_SIZE. */
|
|
951
1052
|
private appendOutput(data: string) {
|
|
952
1053
|
if (!data) return;
|
|
@@ -964,6 +1065,9 @@ export class Room extends EventEmitter {
|
|
|
964
1065
|
this.outputChunks[0] = only;
|
|
965
1066
|
this.outputBytes = only.length;
|
|
966
1067
|
}
|
|
1068
|
+
// Lazily-created grids hydrate from the ring at creation; here the grid
|
|
1069
|
+
// (when it exists) just follows along with the delta.
|
|
1070
|
+
this.grid?.write(data);
|
|
967
1071
|
}
|
|
968
1072
|
|
|
969
1073
|
/**
|
|
@@ -989,6 +1093,8 @@ export class Room extends EventEmitter {
|
|
|
989
1093
|
if (this.ended) return;
|
|
990
1094
|
this.ended = true;
|
|
991
1095
|
this.stopCwdPolling();
|
|
1096
|
+
this.grid?.dispose();
|
|
1097
|
+
this.grid = null;
|
|
992
1098
|
if (this.cleanupTimer) {
|
|
993
1099
|
clearTimeout(this.cleanupTimer);
|
|
994
1100
|
this.cleanupTimer = null;
|
|
@@ -1101,6 +1207,9 @@ export class RoomManager {
|
|
|
1101
1207
|
|
|
1102
1208
|
constructor(ptyFactory: PtyFactory) {
|
|
1103
1209
|
this.ptyFactory = ptyFactory;
|
|
1210
|
+
// Start loading the headless deps now so the first attach can serialize;
|
|
1211
|
+
// rooms hydrate their grid from the ring when the load wins the race late.
|
|
1212
|
+
if (SERIALIZED_ATTACH_ENABLED) void loadScreenGridDeps();
|
|
1104
1213
|
}
|
|
1105
1214
|
|
|
1106
1215
|
getRoom(roomId: string, initialSize: { cols: number; rows: number }, cwdOrOptions: string | RoomCreateOptions) {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Server-side screen state — the authoritative grid behind serialized attach.
|
|
2
|
+
//
|
|
3
|
+
// The attach snapshot used to be a raw byte TAIL of the output ring. For
|
|
4
|
+
// incremental TUIs (Claude Code/Ink) a tail is structurally incomplete: rows
|
|
5
|
+
// that haven't changed recently were emitted before the tail window began, so
|
|
6
|
+
// a freshly attached client rendered holes until the app happened to repaint.
|
|
7
|
+
// That is the whole "have to scroll before it renders correctly" bug — the
|
|
8
|
+
// wheel event reaches the app through mouse reporting and provokes a redraw
|
|
9
|
+
// that resends the pixels the snapshot never carried.
|
|
10
|
+
//
|
|
11
|
+
// Feeding the room's output through a headless terminal instead means every
|
|
12
|
+
// attach can serialize the complete current screen — a few KB, always whole —
|
|
13
|
+
// and the resize "wiggle" repaint trick becomes unnecessary on this path.
|
|
14
|
+
//
|
|
15
|
+
// Loaded lazily and failure-tolerant: when @xterm/headless is unavailable a
|
|
16
|
+
// room silently falls back to the raw-tail snapshot (+ wiggle), so a broken
|
|
17
|
+
// dependency degrades to the old behavior instead of breaking attach.
|
|
18
|
+
|
|
19
|
+
type HeadlessTerminal = {
|
|
20
|
+
write(data: string, callback?: () => void): void;
|
|
21
|
+
resize(cols: number, rows: number): void;
|
|
22
|
+
loadAddon(addon: unknown): void;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type SerializeAddonLike = {
|
|
27
|
+
serialize(options?: { scrollback?: number }): string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type GridDeps = {
|
|
31
|
+
Terminal: new (options: Record<string, unknown>) => HeadlessTerminal;
|
|
32
|
+
SerializeAddon: new () => SerializeAddonLike;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Scrollback lines the grid retains (and serialized attaches can carry).
|
|
36
|
+
// This bounds host memory per room — deeper history stays in the raw output
|
|
37
|
+
// ring and reaches clients through the existing deep-history replay path.
|
|
38
|
+
export const GRID_SCROLLBACK = (() => {
|
|
39
|
+
const env = Number(process.env.HAY_GRID_SCROLLBACK);
|
|
40
|
+
return Number.isFinite(env) && env >= 0 ? Math.floor(env) : 1000;
|
|
41
|
+
})();
|
|
42
|
+
|
|
43
|
+
let deps: GridDeps | null = null;
|
|
44
|
+
let loading: Promise<boolean> | null = null;
|
|
45
|
+
|
|
46
|
+
export const screenGridsAvailable = (): boolean => deps !== null;
|
|
47
|
+
|
|
48
|
+
export const loadScreenGridDeps = (): Promise<boolean> => {
|
|
49
|
+
if (deps) return Promise.resolve(true);
|
|
50
|
+
if (loading) return loading;
|
|
51
|
+
loading = (async () => {
|
|
52
|
+
try {
|
|
53
|
+
// Some xterm builds touch browser globals at import time; shim the few
|
|
54
|
+
// they reach for (same trick the daemon uses for preview rendering).
|
|
55
|
+
const g = globalThis as Record<string, unknown>;
|
|
56
|
+
if (!g.window) g.window = g;
|
|
57
|
+
if (!g.self) g.self = g;
|
|
58
|
+
const [headless, serialize] = await Promise.all([
|
|
59
|
+
import("@xterm/headless"),
|
|
60
|
+
import("@xterm/addon-serialize")
|
|
61
|
+
]);
|
|
62
|
+
// Both packages ship UMD/CJS: under Node's ESM interop the named
|
|
63
|
+
// exports may only exist on `default`, so check both shapes.
|
|
64
|
+
type HeadlessModule = { Terminal?: GridDeps["Terminal"]; default?: { Terminal?: GridDeps["Terminal"] } };
|
|
65
|
+
type SerializeModule = { SerializeAddon?: GridDeps["SerializeAddon"]; default?: { SerializeAddon?: GridDeps["SerializeAddon"] } };
|
|
66
|
+
const Terminal = (headless as HeadlessModule).Terminal ?? (headless as HeadlessModule).default?.Terminal;
|
|
67
|
+
const SerializeAddon = (serialize as SerializeModule).SerializeAddon
|
|
68
|
+
?? (serialize as SerializeModule).default?.SerializeAddon;
|
|
69
|
+
if (typeof Terminal !== "function" || typeof SerializeAddon !== "function") {
|
|
70
|
+
console.log("[hay] serialized attach unavailable (xterm exports not found) — raw-tail snapshots");
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
deps = { Terminal, SerializeAddon };
|
|
74
|
+
return true;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.log(`[hay] serialized attach unavailable (${(error as Error)?.message || "load failed"}) — raw-tail snapshots`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
return loading;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type ScreenGrid = {
|
|
84
|
+
write(data: string): void;
|
|
85
|
+
resize(cols: number, rows: number): void;
|
|
86
|
+
/**
|
|
87
|
+
* Serialize AFTER everything already written has been parsed (xterm's
|
|
88
|
+
* write path is asynchronous). Calls back with null on failure so the
|
|
89
|
+
* caller can fall back to the raw-tail snapshot. `scrollback` bounds how
|
|
90
|
+
* many history lines ride along (tiles need far fewer than a full screen).
|
|
91
|
+
*/
|
|
92
|
+
serialize(callback: (data: string | null) => void, scrollback?: number): void;
|
|
93
|
+
dispose(): void;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null => {
|
|
97
|
+
if (!deps) return null;
|
|
98
|
+
let term: HeadlessTerminal;
|
|
99
|
+
let addon: SerializeAddonLike;
|
|
100
|
+
try {
|
|
101
|
+
term = new deps.Terminal({
|
|
102
|
+
cols: Math.max(2, cols),
|
|
103
|
+
rows: Math.max(1, rows),
|
|
104
|
+
scrollback: GRID_SCROLLBACK,
|
|
105
|
+
allowProposedApi: true
|
|
106
|
+
});
|
|
107
|
+
addon = new deps.SerializeAddon();
|
|
108
|
+
term.loadAddon(addon);
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
let disposed = false;
|
|
113
|
+
return {
|
|
114
|
+
write(data: string) {
|
|
115
|
+
if (disposed) return;
|
|
116
|
+
try { term.write(data); } catch { /* parser hiccup — next write continues */ }
|
|
117
|
+
},
|
|
118
|
+
resize(nextCols: number, nextRows: number) {
|
|
119
|
+
if (disposed) return;
|
|
120
|
+
try { term.resize(Math.max(2, nextCols), Math.max(1, nextRows)); } catch { /* keep old size */ }
|
|
121
|
+
},
|
|
122
|
+
serialize(callback: (data: string | null) => void, scrollback: number = GRID_SCROLLBACK) {
|
|
123
|
+
if (disposed) { callback(null); return; }
|
|
124
|
+
try {
|
|
125
|
+
// An empty write's callback fires once all queued data has parsed —
|
|
126
|
+
// the serialize then reflects every byte up to this attach.
|
|
127
|
+
term.write("", () => {
|
|
128
|
+
if (disposed) { callback(null); return; }
|
|
129
|
+
try {
|
|
130
|
+
callback(addon.serialize({ scrollback: Math.max(0, Math.min(scrollback, GRID_SCROLLBACK)) }));
|
|
131
|
+
} catch {
|
|
132
|
+
callback(null);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
callback(null);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
dispose() {
|
|
140
|
+
if (disposed) return;
|
|
141
|
+
disposed = true;
|
|
142
|
+
try { term.dispose(); } catch { /* already gone */ }
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
};
|
|
@@ -707,4 +707,108 @@ describe("Room", () => {
|
|
|
707
707
|
expect(delta.data).toBe("fresh");
|
|
708
708
|
});
|
|
709
709
|
});
|
|
710
|
+
|
|
711
|
+
describe("serialized attach", () => {
|
|
712
|
+
const setup = async (roomId: string) => {
|
|
713
|
+
const { loadScreenGridDeps } = await import("../src/screenGrid");
|
|
714
|
+
expect(await loadScreenGridDeps()).toBe(true);
|
|
715
|
+
let ptyInstance: FakePty | null = null;
|
|
716
|
+
const factory: PtyFactory = () => {
|
|
717
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
718
|
+
return ptyInstance as any;
|
|
719
|
+
};
|
|
720
|
+
const manager = new RoomManager(factory);
|
|
721
|
+
const room = manager.getRoom(roomId, { cols: 80, rows: 24 }, "/tmp");
|
|
722
|
+
return { room, pty: () => ptyInstance! };
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const waitForSnapshot = async (socket: FakeSocket) => {
|
|
726
|
+
await vi.waitFor(() => {
|
|
727
|
+
expect(findMessages(socket, "snapshot").length).toBeGreaterThan(0);
|
|
728
|
+
});
|
|
729
|
+
return findMessages(socket, "snapshot")[0] as any;
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
// THE recurring bug, reproduced: an incremental TUI (Claude Code/Ink)
|
|
733
|
+
// paints a row once and never re-emits it; once enough updates pass, the
|
|
734
|
+
// row leaves every bounded raw tail. A client attaching off such a tail
|
|
735
|
+
// rendered holes until the app happened to repaint — "have to scroll
|
|
736
|
+
// before it renders correctly". The serialized grid carries the row no
|
|
737
|
+
// matter how long ago it was painted.
|
|
738
|
+
it("carries rows the bounded raw tail lost, without the wiggle", async () => {
|
|
739
|
+
const { room, pty } = await setup("serial");
|
|
740
|
+
// The app enables mouse tracking (as Claude Code does at startup) —
|
|
741
|
+
// long outside any bounded tail, and never emitted by serialization.
|
|
742
|
+
pty().emit("\x1b[?1002h\x1b[?1006h");
|
|
743
|
+
pty().emit("\x1b[2J\x1b[1;1HROW1-STABLE-BORDER-MARKER");
|
|
744
|
+
// In-place repaints of one region, Ink-style: no scrolling, so the
|
|
745
|
+
// marker row stays ON SCREEN while leaving every bounded byte tail.
|
|
746
|
+
const update = "\x1b[20;1H\x1b[K" + "x".repeat(60);
|
|
747
|
+
for (let i = 0; i < 1200; i++) pty().emit(update); // ~84KB > the 64KB ask
|
|
748
|
+
const socket = new FakeSocket();
|
|
749
|
+
room.attachClient(
|
|
750
|
+
{ id: "tile", name: "Tile", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
|
|
751
|
+
socket
|
|
752
|
+
);
|
|
753
|
+
const snapshot = await waitForSnapshot(socket);
|
|
754
|
+
expect(String(snapshot.data).startsWith("\x1bc")).toBe(true);
|
|
755
|
+
expect(String(snapshot.data)).toContain("ROW1-STABLE-BORDER-MARKER");
|
|
756
|
+
// The raw tail this connection would have gotten cannot contain it.
|
|
757
|
+
expect(room.getPreviewSource(65536).output).not.toContain("ROW1-STABLE-BORDER-MARKER");
|
|
758
|
+
// Complete screen delivered — nothing needs the repaint nudge.
|
|
759
|
+
expect(pty().resizes.some((r: { cols: number }) => r.cols === 79)).toBe(false);
|
|
760
|
+
// ~82KB of raw history is well under the deep-history hint threshold.
|
|
761
|
+
expect(snapshot.capped).toBe(false);
|
|
762
|
+
// Private modes ride along in-band: the attaching terminal must know
|
|
763
|
+
// the app owns the wheel, or tiles scroll their local buffer instead.
|
|
764
|
+
expect(String(snapshot.data)).toContain("\x1b[?1002h");
|
|
765
|
+
expect(String(snapshot.data)).toContain("\x1b[?1006h");
|
|
766
|
+
expect(snapshot.mouseReporting).toBe(true);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
|
|
770
|
+
const { room, pty } = await setup("capped");
|
|
771
|
+
const big = "\x1b[20;1H" + "y".repeat(4096);
|
|
772
|
+
for (let i = 0; i < 80; i++) pty().emit(big); // ~330KB ring
|
|
773
|
+
const socket = new FakeSocket();
|
|
774
|
+
room.attachClient(
|
|
775
|
+
{ id: "web", name: "Web", colorIndex: 0, cols: 80, rows: 24, replayBytes: 393216 },
|
|
776
|
+
socket
|
|
777
|
+
);
|
|
778
|
+
const snapshot = await waitForSnapshot(socket);
|
|
779
|
+
expect(String(snapshot.data).startsWith("\x1bc")).toBe(true);
|
|
780
|
+
expect(snapshot.capped).toBe(true);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("replay=0 means no snapshot at all — live stream only", async () => {
|
|
784
|
+
const { room, pty } = await setup("claim");
|
|
785
|
+
pty().emit("some prior output\r\n");
|
|
786
|
+
const socket = new FakeSocket();
|
|
787
|
+
room.attachClient(
|
|
788
|
+
{ id: "claim", name: "Claim", colorIndex: 0, cols: 80, rows: 24, replayBytes: 0, nudge: false },
|
|
789
|
+
socket
|
|
790
|
+
);
|
|
791
|
+
// Give any (buggy) async snapshot path a chance to fire before asserting.
|
|
792
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
793
|
+
expect(findMessages(socket, "snapshot")).toHaveLength(0);
|
|
794
|
+
expect(pty().resizes.some((r: { cols: number }) => r.cols === 79)).toBe(false);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("a deep-history request keeps the raw tail replay", async () => {
|
|
798
|
+
const { room, pty } = await setup("deep");
|
|
799
|
+
pty().emit("\x1b[2J\x1b[1;1Hdeep-history-anchor\r\n");
|
|
800
|
+
pty().emit("z".repeat(200_000));
|
|
801
|
+
const socket = new FakeSocket();
|
|
802
|
+
room.attachClient(
|
|
803
|
+
{ id: "deep", name: "Deep", colorIndex: 0, cols: 80, rows: 24, replayBytes: 1_572_864 },
|
|
804
|
+
socket
|
|
805
|
+
);
|
|
806
|
+
// Raw path is synchronous.
|
|
807
|
+
const snapshot = findMessages(socket, "snapshot")[0] as any;
|
|
808
|
+
expect(snapshot).toBeTruthy();
|
|
809
|
+
expect(String(snapshot.data).startsWith("\x1bc")).toBe(false);
|
|
810
|
+
expect(String(snapshot.data).length).toBeGreaterThan(200_000);
|
|
811
|
+
expect(String(snapshot.data)).toContain("deep-history-anchor");
|
|
812
|
+
});
|
|
813
|
+
});
|
|
710
814
|
});
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -1430,9 +1430,12 @@ const App = () => {
|
|
|
1430
1430
|
remoteMouseSgrRef.current = message.mouseSgr === true;
|
|
1431
1431
|
optimisticEchoRef.current.reset();
|
|
1432
1432
|
userScrolledUpRef.current = false;
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
|
|
1433
|
+
// Deeper history exists than this snapshot carries: the server says
|
|
1434
|
+
// so outright for serialized snapshots (which are a few KB no
|
|
1435
|
+
// matter how deep the session is), and for raw tails a payload near
|
|
1436
|
+
// the requested bound was almost certainly cut.
|
|
1437
|
+
snapshotWasCappedRef.current = (message.capped === true
|
|
1438
|
+
|| (typeof message.data === "string" && message.data.length >= 350000))
|
|
1436
1439
|
&& !deepReplayRoomsRef.current.has(activeSessionRoomRef.current || "");
|
|
1437
1440
|
// Reset IN the stream (RIS) rather than as a separate call. A bare
|
|
1438
1441
|
// reset() paints an empty terminal immediately, while the snapshot
|
|
@@ -1448,7 +1451,13 @@ const App = () => {
|
|
|
1448
1451
|
deepRestoreRef.current = null;
|
|
1449
1452
|
if (termRef.current) {
|
|
1450
1453
|
const t = termRef.current;
|
|
1451
|
-
|
|
1454
|
+
// Re-assert remote modes in-band after the snapshot: serialized
|
|
1455
|
+
// snapshots carry the screen but not private modes, and xterm's
|
|
1456
|
+
// own mode state gates scroll ownership and selection behavior
|
|
1457
|
+
// (the refs above cover synthesized reports, not xterm itself).
|
|
1458
|
+
const modeSeq = (message.mouseReporting ? "\x1b[?1002h" + (message.mouseSgr ? "\x1b[?1006h" : "") : "")
|
|
1459
|
+
+ (message.cursorHidden ? "\x1b[?25l" : "");
|
|
1460
|
+
t.write(`\x1bc${message.data}${modeSeq}`, () => {
|
|
1452
1461
|
if (pendingRestore) {
|
|
1453
1462
|
// The user was reading at the top of the OLD buffer; that
|
|
1454
1463
|
// content now sits newLen - oldLen lines down. Land there,
|