hop2 0.11.10 → 0.11.12

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 CHANGED
@@ -23,6 +23,7 @@
23
23
  - **Status bar / hints toggles are session-only**: `Opt+B` (status bar) and `Opt+T` (hints) no longer persist to `.hop.json`, so an in-session toggle can't silently become your permanent default. The default is taken from `.hop.json` (`showStatusBar`/`showHints`, both default `true`); toggles affect only the current session and the notice now says so. (Other toggles like mouse capture and sync-size still persist.)
24
24
 
25
25
  #### Web Client
26
+ - **One size rule everywhere — the last user action owns the terminal size**: the server-side size election (typing-recency plus 60s/2.5s idle windows) is replaced by a single ownership rule: whoever acted last — opened the session or pressed a key — owns the PTY size, and their refits (window drag, rotation, keyboard) flow straight through. A keystroke transfers ownership and snaps the PTY to the typist's declared fit immediately. `active_size` is now interpreted by **identity** (the owner's clientId) instead of by comparing sizes. When a peer owns the size, the web terminal in Auto-fit now **follows the owner's grid** — rendered at the PTY's true geometry, scaled and letterboxed to the viewport — instead of rewrapping the buffer at its own width; typing (or the drawer's Fit button) takes the size back. Scrolling doesn't count: wheel/mouse reports never transfer ownership, so reading a session on the phone no longer steals the size from a desk. Resize sends are debounced (150ms) and the stale pre-switch resize on reconnect is gone, so a session switch reshapes the PTY once, not twice.
26
27
  - **Restyle to the hop identity**: the web client now shares the CLI bars' palette and language — accent session chip, semantic state dot, and keycap-style find control in a real status-bar footer; mono-led chrome (brand, presence names, session labels); refined light/dark themes; dot-grid join page. The hop session picker got the same treatment, including dark-mode support.
27
28
  - **Drawer polish**: quick actions split into two clusters — utility icons (keyboard / share / find) then named actions (Fit / Manage) — and the drawer FAB now defaults to the top-right (clear of the status bar, mirroring the close button) instead of bottom-left. It stays draggable.
28
29
  - **Autofit by default + fit on every session load**: the terminal fits the viewport on connect and on each session switch, on every platform. Autofit resizes the shared PTY, so other viewers follow this client; switch to Manual in the drawer to opt out.
@@ -34,6 +35,7 @@
34
35
 
35
36
  #### Web terminal
36
37
  - **Claude screens render completely on attach without a scroll**: Hop now starts its authoritative screen grid when a session enters alternate-screen or mouse-reporting TUI mode, before startup rows can fall out of a bounded replay tail. Full-screen attaches use that serialized screen, and direct snapshot paints re-arm one delayed repaint in case the browser misses the first WebGL frame.
38
+ - **Resizing another desktop view no longer leaves a terminal unable to reach the live bottom**: Wall/watch tiles now resize only unattended sessions instead of overriding an open desktop's Claude grid, Hop reconciles xterm's follow state after local geometry changes, and authoritative screen resizes stay ordered behind the output that preceded them.
37
39
 
38
40
  #### Session restore
39
41
  - **`hop restore` actually restores now (Claude conversations included), from the SessionStart-hook records**: restore built its candidate list from the workspace-scoped session store, which is only populated when a workspace is active — and no workspace is ever activated in normal use, so `restore` found nothing and silently restored zero sessions. It now sources candidates directly from the durable per-session records the SessionStart hook writes (`~/.hop2/claude-sessions/<name>.json` = `{ sessionId, cwd }`), which survive a crash/reboot and carry the exact conversation id. So after a restart, `hop restore` recreates each session in its directory and resumes its Claude conversation with `claude --resume <id>` — no manual capture step. Sessions already live are skipped (idempotent), and `hop restore` now starts the daemon itself if it isn't running, so post-reboot recovery is a single command. `--dry-run` previews the plan. Records are pruned when a session is deleted, so restore never resurrects a session you intentionally closed (and if an unwanted one does come back, deleting it once keeps it gone).
@@ -75,6 +75,7 @@ wss.on("connection", (ws, req) => {
75
75
  const url = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`);
76
76
  const roomId = sanitizeRoom(url.searchParams.get("room"));
77
77
  const name = sanitizeName(url.searchParams.get("name"));
78
+ const source = url.searchParams.get("source") || "";
78
79
  const cols = Number(url.searchParams.get("cols") ?? 80);
79
80
  const rows = Number(url.searchParams.get("rows") ?? 24);
80
81
 
@@ -91,6 +92,7 @@ wss.on("connection", (ws, req) => {
91
92
  {
92
93
  id: clientId,
93
94
  name,
95
+ source,
94
96
  colorIndex,
95
97
  cols,
96
98
  rows
@@ -119,6 +119,7 @@ export function attachTermshare(options: TermshareServerOptions) {
119
119
  const url = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`);
120
120
  const roomId = sanitizeRoom(url.searchParams.get("room"));
121
121
  const name = sanitizeName(url.searchParams.get("name"));
122
+ const source = url.searchParams.get("source") || "";
122
123
  const cols = Number(url.searchParams.get("cols") ?? 80);
123
124
  const rows = Number(url.searchParams.get("rows") ?? 24);
124
125
 
@@ -135,6 +136,7 @@ export function attachTermshare(options: TermshareServerOptions) {
135
136
  {
136
137
  id: clientId,
137
138
  name,
139
+ source,
138
140
  colorIndex,
139
141
  cols,
140
142
  rows
@@ -117,25 +117,20 @@ const SERIALIZED_MAX_REQUEST = 524_288;
117
117
  // snapshot never trips).
118
118
  const SERIALIZED_CAPPED_HINT_BYTES = 262_144;
119
119
 
120
- // Autofit-on-attach: a resize may claim the shared size when every OTHER
121
- // client has been input-idle at least this long. Someone actively typing keeps
122
- // the size they set; a viewer opening the session after everyone went idle
123
- // takes it over immediately instead of waiting to type first.
124
- const RESIZE_CLAIM_IDLE_MS = (() => {
125
- const env = Number(process.env.HAY_RESIZE_CLAIM_IDLE_MS);
126
- return Number.isFinite(env) && env >= 0 ? env : 60_000;
127
- })();
128
- // A claim:"attach" resize (a fresh client's first autofit) is deliberate:
129
- // someone just opened the session there. It takes the shared size unless a
130
- // peer typed within this window — only an actively-typing peer holds it.
131
- // 2.5s (was 5s): an actively typing peer keeps refreshing its own window, so
132
- // this only decides how long a PAUSED typist blocks a deliberate new attach —
133
- // measured 5s made wall-tile focus feel stuck (converged at ~5s through the
134
- // tunnel; ~3s with this).
135
- const ATTACH_CLAIM_IDLE_MS = (() => {
136
- const env = Number(process.env.HAY_ATTACH_CLAIM_IDLE_MS);
137
- return Number.isFinite(env) && env >= 0 ? env : 2_500;
138
- })();
120
+ // Size ownership: the last USER ACTION owns the PTY size — opening the
121
+ // session (a claim:"attach" or user:true resize) or a real keystroke,
122
+ // whichever happened last. No idle windows, no election. The owner's
123
+ // resizes apply unconditionally (their viewport IS the session's size);
124
+ // everyone else's are recorded as that client's declared fit and answered
125
+ // with a corrective active_size so followers can adopt the owner's grid.
126
+ // When a keystroke transfers ownership, the PTY immediately takes the new
127
+ // owner's declared fit. Monitors (source=monitor: wall tiles, preview
128
+ // sockets) never own the size; they may size an UNATTENDED session so
129
+ // previews render true. Wheel/mouse reports are reading, not typing, and
130
+ // transfer nothing. SGR mouse reports only — every hop client sends wheel
131
+ // as CSI < b;x;y M/m. Anchored and exclusive: a payload containing
132
+ // anything besides mouse reports still counts as typing.
133
+ const PURE_MOUSE_REPORT = /^(?:\x1b\[<\d+;\d+;\d+[Mm])+$/;
139
134
  const CONTROL_SEQUENCE_TAIL = 32;
140
135
  const DEBUG_STATE = process.env.HAY_DEBUG === "1";
141
136
 
@@ -185,12 +180,11 @@ class ClientState {
185
180
  color: string;
186
181
  typing = false;
187
182
  lastActive = now();
188
- // Timestamp of this client's last real keystroke input (not resize, not the
189
- // typing indicator, not connect). 0 = has never typed. Drives the active PTY
190
- // size election so size follows the active typer, not whoever last resized.
191
- lastInputAt = 0;
192
183
  socket: SocketAdapter;
193
184
  source: string;
185
+ // Declared fit: the size this client's viewport wants, updated by every
186
+ // resize message whether or not it applied. Becomes the PTY size the
187
+ // moment a keystroke makes this client the size owner.
194
188
  cols: number;
195
189
  rows: number;
196
190
 
@@ -224,7 +218,9 @@ export class Room extends EventEmitter {
224
218
  private clients = new Map<string, ClientState>();
225
219
  private collabMode = true;
226
220
  private controllerId: string | null = null;
227
- private activeClientId: string | null = null;
221
+ // The client whose last user action (open or keystroke) owns the PTY size.
222
+ // null = up for grabs (fresh room, or the owner detached).
223
+ private sizeOwnerId: string | null = null;
228
224
  private activeCols: number;
229
225
  private activeRows: number;
230
226
  // Retained raw output as a chunk ring: append is O(1) and trimming drops
@@ -385,7 +381,7 @@ export class Room extends EventEmitter {
385
381
  socket.send(
386
382
  JSON.stringify({
387
383
  type: "active_size",
388
- clientId: this.activeClientId ?? client.id,
384
+ clientId: this.sizeOwnerId ?? client.id,
389
385
  cols: this.activeCols,
390
386
  rows: this.activeRows
391
387
  } satisfies ServerMessage)
@@ -575,11 +571,12 @@ export class Room extends EventEmitter {
575
571
  const combined = this.controlSequenceTail + data;
576
572
  const tailLength = this.controlSequenceTail.length;
577
573
  const risIndex = combined.lastIndexOf("\x1bc");
578
- // Only process a RIS that ends in this chunk; one entirely inside the
579
- // carry tail was already applied on the preceding call. Modes before the
580
- // last new RIS are reset, while later sequences in the same chunk win.
574
+ // Only reset tracked state for a RIS that ends in this chunk; one entirely
575
+ // inside the carry tail was already applied on the preceding call. Always
576
+ // ignore bytes before the last RIS so carried pre-reset mode enables cannot
577
+ // be parsed again on the next chunk.
581
578
  const hasNewRis = risIndex !== -1 && risIndex + 2 > tailLength;
582
- const stateInput = hasNewRis ? combined.slice(risIndex + 2) : combined;
579
+ const stateInput = risIndex !== -1 ? combined.slice(risIndex + 2) : combined;
583
580
 
584
581
  // Parse CSI mode sequences (cursor visibility, alternate screen)
585
582
  const regex = /\x1b\[\?([0-9;]*)([hl])/g;
@@ -879,7 +876,21 @@ export class Room extends EventEmitter {
879
876
  return;
880
877
  }
881
878
  client.lastActive = now();
882
- client.lastInputAt = now();
879
+ // A real keystroke takes the size: transfer ownership and bring the PTY
880
+ // to the new owner's declared fit immediately, so typing from a phone
881
+ // reshapes the session even before its next resize message arrives.
882
+ // Wheel/mouse reports are reading, not typing — a phone scrolling claude
883
+ // must not take the size from someone typing at a desk.
884
+ if (
885
+ client.source !== "monitor" &&
886
+ this.sizeOwnerId !== client.id &&
887
+ !PURE_MOUSE_REPORT.test(data)
888
+ ) {
889
+ this.sizeOwnerId = client.id;
890
+ if (client.cols !== this.activeCols || client.rows !== this.activeRows) {
891
+ this.applySize(client, client.cols, client.rows);
892
+ }
893
+ }
883
894
  this.pty.write(data);
884
895
  this.emit("pty_input", { roomId: this.id, clientId: client.id, data, timestamp: now() });
885
896
  }
@@ -888,60 +899,76 @@ export class Room extends EventEmitter {
888
899
  if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
889
900
  if (cols < 1 || cols > 500 || rows < 1 || rows > 200) return;
890
901
 
902
+ // Always record the declared fit, applied or not — a later keystroke
903
+ // makes it the PTY size (see handleInput).
891
904
  client.cols = cols;
892
905
  client.rows = rows;
893
- // The active PTY size follows whoever is actively typing — not whoever last
894
- // resized. Previously a resize bumped lastActive and then checked "am I the
895
- // most active?", which was trivially true for the resizer, so every resize
896
- // won: a passive viewer resizing their window yanked the size from the
897
- // active typer, and two differently-sized clients flapped. Elect on typing
898
- // recency (lastInputAt) instead. A client that has never typed (lastInputAt
899
- // 0) only wins when no one else has typed either — so the first/sole client
900
- // can still size the PTY before typing.
901
- const maxInputAt = Math.max(...[...this.clients.values()].map((c) => c.lastInputAt));
902
- const othersMaxInputAt = Math.max(
903
- 0,
904
- ...[...this.clients.values()].filter((c) => c.id !== client.id).map((c) => c.lastInputAt)
905
- );
906
- const claimIdleMs = claim === "attach" ? ATTACH_CLAIM_IDLE_MS : RESIZE_CLAIM_IDLE_MS;
907
- // A user-flagged claim is a deliberate human act (clicking a wall tile,
908
- // switching to the session): it wins outright. Anyone actively typing
909
- // elsewhere reclaims with their next keystroke, so misfires self-heal.
910
- const isActive = userClaim ||
911
- client.lastInputAt >= maxInputAt || now() - othersMaxInputAt > claimIdleMs;
912
- if (isActive) {
913
- this.pty.resize(cols, rows);
914
- this.activeCols = cols;
915
- this.activeRows = rows;
916
- this.grid?.resize(cols, rows);
917
- this.activeClientId = client.id;
918
- // Broadcast the new active size to other clients so they can adjust —
919
- // and CONFIRM to the winner: a claimant otherwise learns nothing on
920
- // success and can only distinguish accept from reject by timeout.
921
- this.broadcastActiveSize(client);
922
- client.socket.send(
923
- JSON.stringify({
924
- type: "active_size",
925
- clientId: client.id,
926
- cols,
927
- rows
928
- } satisfies ServerMessage)
929
- );
930
- this.emit("pty_resize", { roomId: this.id, clientId: client.id, cols, rows, timestamp: now() });
931
- } else if (cols !== this.activeCols || rows !== this.activeRows) {
932
- // The resize lost the election (a passive viewer auto-fitted itself).
933
- // Snap that client back to the shared size — otherwise its local
934
- // terminal re-wraps the buffer at a size the PTY isn't, and it has no
935
- // way to learn the real size until the next winning resize.
936
- client.socket.send(
937
- JSON.stringify({
938
- type: "active_size",
939
- clientId: this.activeClientId ?? client.id,
940
- cols: this.activeCols,
941
- rows: this.activeRows
942
- } satisfies ServerMessage)
906
+
907
+ // A wall/watch tile may size an unattended session so previews render
908
+ // true, but must never clip a live client by forcing Claude's alternate
909
+ // screen to the tile's grid — and never takes ownership.
910
+ if (client.source === "monitor") {
911
+ const hasInteractivePeer = [...this.clients.values()].some(
912
+ (peer) => peer.id !== client.id && peer.source !== "monitor"
943
913
  );
914
+ if (!hasInteractivePeer) {
915
+ this.applySize(client, cols, rows);
916
+ } else if (cols !== this.activeCols || rows !== this.activeRows) {
917
+ this.sendActiveSize(client, this.sizeOwnerId ?? client.id);
918
+ }
919
+ return;
920
+ }
921
+
922
+ // Opening the session here (claim:"attach") or a deliberate click
923
+ // (user:true) is a user action: strict recency, it takes ownership
924
+ // outright. Whoever was typing elsewhere takes it back with their next
925
+ // keystroke, so misfires self-heal.
926
+ if (claim === "attach" || userClaim) {
927
+ this.sizeOwnerId = client.id;
928
+ this.applySize(client, cols, rows);
929
+ return;
944
930
  }
931
+
932
+ // The owner's viewport IS the session's size: its refits (rotation,
933
+ // keyboard show/hide, window drag) flow straight through. While nobody
934
+ // owns the size (fresh room, owner detached), any interactive client
935
+ // may size it without claiming it.
936
+ if (this.sizeOwnerId === client.id || this.sizeOwnerId === null) {
937
+ this.applySize(client, cols, rows);
938
+ return;
939
+ }
940
+
941
+ // Not the owner: the declaration is recorded; snap the client back to
942
+ // the shared size — otherwise its local terminal re-wraps the buffer at
943
+ // a size the PTY isn't, and it has no way to learn the real size until
944
+ // the next broadcast.
945
+ if (cols !== this.activeCols || rows !== this.activeRows) {
946
+ this.sendActiveSize(client, this.sizeOwnerId);
947
+ }
948
+ }
949
+
950
+ private applySize(client: ClientState, cols: number, rows: number) {
951
+ this.pty.resize(cols, rows);
952
+ this.activeCols = cols;
953
+ this.activeRows = rows;
954
+ this.grid?.resize(cols, rows);
955
+ // Broadcast to the others so they can adopt — and CONFIRM to the
956
+ // applier: a claimant otherwise learns nothing on success and can only
957
+ // distinguish accept from reject by timeout.
958
+ this.broadcastActiveSize(client);
959
+ this.sendActiveSize(client, client.id);
960
+ this.emit("pty_resize", { roomId: this.id, clientId: client.id, cols, rows, timestamp: now() });
961
+ }
962
+
963
+ private sendActiveSize(client: ClientState, ownerId: string) {
964
+ client.socket.send(
965
+ JSON.stringify({
966
+ type: "active_size",
967
+ clientId: ownerId,
968
+ cols: this.activeCols,
969
+ rows: this.activeRows
970
+ } satisfies ServerMessage)
971
+ );
945
972
  }
946
973
 
947
974
  private broadcastActiveSize(client: ClientState) {
@@ -994,6 +1021,11 @@ export class Room extends EventEmitter {
994
1021
  private removeClient(clientId: string) {
995
1022
  const wasController = this.controllerId === clientId;
996
1023
  this.clients.delete(clientId);
1024
+ if (this.sizeOwnerId === clientId) {
1025
+ // The owner left: the size is up for grabs, so a remaining client's
1026
+ // next refit (not just its next keystroke) may take it.
1027
+ this.sizeOwnerId = null;
1028
+ }
997
1029
  if (wasController && !this.collabMode) {
998
1030
  this.collabMode = true;
999
1031
  this.controllerId = null;
@@ -1214,6 +1246,29 @@ export class Room extends EventEmitter {
1214
1246
  return { cols: this.activeCols, rows: this.activeRows, output: this.tailOutput(maxBytes) };
1215
1247
  }
1216
1248
 
1249
+ /**
1250
+ * The room's current screen, serialized — IDENTICAL in geometry and bytes
1251
+ * to what a live attach snapshots. Previews painted from this cannot
1252
+ * disagree with the terminal a click turns them into: that disagreement
1253
+ * (daemon-side grids rendering at their own capped size while the room ran
1254
+ * at another) was the preview → terminal size change. Modes ride in-band
1255
+ * like the attach path so cursor/mouse state present identically too.
1256
+ */
1257
+ serializeScreen(
1258
+ callback: (result: { data: string; cols: number; rows: number } | null) => void,
1259
+ scrollback = 200
1260
+ ) {
1261
+ if (this.outputBytes === 0) { callback(null); return; }
1262
+ const grid = this.ensureGrid();
1263
+ if (!grid) { callback(null); return; }
1264
+ grid.serialize((serialized) => {
1265
+ if (serialized === null) { callback(null); return; }
1266
+ const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
1267
+ + (this.cursorHidden ? "\x1b[?25l" : "");
1268
+ callback({ data: "\x1bc" + serialized + modeSeq, cols: this.activeCols, rows: this.activeRows });
1269
+ }, scrollback);
1270
+ }
1271
+
1217
1272
  /**
1218
1273
  * Incremental output for a persistent consumer-side screen parser (the
1219
1274
  * daemon's preview grids). `since` is an absolute cursor from a previous
@@ -1353,6 +1408,16 @@ export class RoomManager {
1353
1408
  return room ? room.getPreviewSource(maxBytes) : null;
1354
1409
  }
1355
1410
 
1411
+ serializeRoomScreen(
1412
+ id: string,
1413
+ callback: (result: { data: string; cols: number; rows: number } | null) => void,
1414
+ scrollback?: number
1415
+ ) {
1416
+ const room = this.rooms.get(id);
1417
+ if (!room) { callback(null); return; }
1418
+ room.serializeScreen(callback, scrollback);
1419
+ }
1420
+
1356
1421
  getRoomOutputSince(id: string, since?: number, maxBytes?: number) {
1357
1422
  const room = this.rooms.get(id);
1358
1423
  return room ? room.getOutputSince(since, maxBytes) : null;
@@ -39,6 +39,9 @@ export const GRID_SCROLLBACK = (() => {
39
39
  const env = Number(process.env.HAY_GRID_SCROLLBACK);
40
40
  return Number.isFinite(env) && env >= 0 ? Math.floor(env) : 1000;
41
41
  })();
42
+ // Preserve ordinary shrink/grow sequences exactly while bounding an abusive
43
+ // resize storm's retained work behind a single xterm write marker.
44
+ const MAX_QUEUED_RESIZE_TRANSITIONS = 256;
42
45
 
43
46
  let deps: GridDeps | null = null;
44
47
  let loading: Promise<boolean> | null = null;
@@ -110,17 +113,52 @@ export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null
110
113
  return null;
111
114
  }
112
115
  let disposed = false;
116
+ let coalescibleResizes: number[] | null = null;
113
117
  return {
114
118
  write(data: string) {
115
119
  if (disposed) return;
120
+ // Output is an ordering boundary: a later resize must run after it,
121
+ // rather than coalescing into a resize marker queued before it.
122
+ if (data) coalescibleResizes = null;
116
123
  try { term.write(data); } catch { /* parser hiccup — next write continues */ }
117
124
  },
118
125
  resize(nextCols: number, nextRows: number) {
119
126
  if (disposed) return;
120
- try { term.resize(Math.max(2, nextCols), Math.max(1, nextRows)); } catch { /* keep old size */ }
127
+ const cols = Math.max(2, nextCols);
128
+ const rows = Math.max(1, nextRows);
129
+ if (coalescibleResizes) {
130
+ if (coalescibleResizes.length < MAX_QUEUED_RESIZE_TRANSITIONS * 2) {
131
+ coalescibleResizes.push(cols, rows);
132
+ } else {
133
+ coalescibleResizes[coalescibleResizes.length - 2] = cols;
134
+ coalescibleResizes[coalescibleResizes.length - 1] = rows;
135
+ }
136
+ return;
137
+ }
138
+ const scheduled = [cols, rows];
139
+ coalescibleResizes = scheduled;
140
+ try {
141
+ // xterm parses write() asynchronously. Queue the resize behind every
142
+ // earlier byte so room order stays PTY output → SIGWINCH, while later
143
+ // writes naturally queue behind this callback's synchronous resize.
144
+ // Consecutive resizes share this marker. Replay ordinary geometry
145
+ // changes exactly: shrink/grow transitions mutate xterm's grid even
146
+ // without output. Only an abusive overflow degrades to the latest size.
147
+ term.write("", () => {
148
+ if (disposed) return;
149
+ if (coalescibleResizes === scheduled) coalescibleResizes = null;
150
+ for (let i = 0; i < scheduled.length; i += 2) {
151
+ try { term.resize(scheduled[i], scheduled[i + 1]); } catch { /* keep previous size */ }
152
+ }
153
+ });
154
+ } catch {
155
+ if (coalescibleResizes === scheduled) coalescibleResizes = null;
156
+ }
121
157
  },
122
158
  serialize(callback: (data: string | null) => void, scrollback: number = GRID_SCROLLBACK) {
123
159
  if (disposed) { callback(null); return; }
160
+ // A later resize must not move ahead of this point-in-time snapshot.
161
+ coalescibleResizes = null;
124
162
  try {
125
163
  // An empty write's callback fires once all queued data has parsed —
126
164
  // the serialize then reflects every byte up to this attach.