hop2 0.11.76 → 0.11.77

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.
@@ -142,6 +142,11 @@ export type RoomSummary = {
142
142
  // 6MB still exceeds every reader: the deep-history replay asks 1.5MB, the
143
143
  // serialized attach reads the grid, and `hop restore` persists 64KB tails.
144
144
  // Override with HAY_SNAPSHOT_BUFFER_BYTES for deeper rings at GC's expense.
145
+ // DEC private modes worth restoring on adopt: cursor keys, mouse (any
146
+ // flavour and encoding), focus events, alternate screen, bracketed paste,
147
+ // cursor visibility.
148
+ const TRACKED_DEC_MODES = new Set(["1", "25", "47", "1000", "1002", "1003", "1004", "1005", "1006", "1015", "1047", "1049", "2004"]);
149
+
145
150
  const MAX_BUFFER_SIZE = (() => {
146
151
  const env = Number(process.env.HAY_SNAPSHOT_BUFFER_BYTES);
147
152
  return Number.isFinite(env) && env > 0 ? env : 6_000_000;
@@ -321,6 +326,13 @@ export class Room extends EventEmitter {
321
326
  private mouseReporting = false;
322
327
  private mouseSgr = false;
323
328
  private cursorHidden = false;
329
+ // The DEC private modes an app set (alternate screen, mouse, focus,
330
+ // bracketed paste…), exactly. An app sets them once at startup; a host
331
+ // restart rebuilds the room from a bounded replay that no longer holds
332
+ // that moment, so the room forgot Codex was full-screen and the wheel
333
+ // scrolled the page instead of the app (2026-10-12). The host saves
334
+ // modePrefix() and puts it back in front of the replay on adopt.
335
+ private decModes = new Map<string, boolean>();
324
336
  private controlSequenceTail = "";
325
337
  private oscBuffer = "";
326
338
  private cleanupTimer: NodeJS.Timeout | null = null;
@@ -678,10 +690,13 @@ export class Room extends EventEmitter {
678
690
 
679
691
  let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
680
692
  let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
693
+ const modesBefore = this.modePrefix();
694
+ if (hasNewRis) this.decModes.clear();
681
695
  while ((match = regex.exec(stateInput)) !== null) {
682
696
  const params = match[1].split(";").filter(Boolean);
683
697
  const mode = match[2];
684
698
  for (const param of params) {
699
+ if (TRACKED_DEC_MODES.has(param)) this.decModes.set(param, mode === "h");
685
700
  if (param === "25") {
686
701
  nextCursorHidden = mode === "l";
687
702
  } else if (param === "47" || param === "1047" || param === "1049") {
@@ -738,6 +753,9 @@ export class Room extends EventEmitter {
738
753
  stateChanged = true;
739
754
  }
740
755
 
756
+ const modesAfter = this.modePrefix();
757
+ if (modesAfter !== modesBefore) this.emit("modes", modesAfter);
758
+
741
759
  if (stateChanged) {
742
760
  this.emit("pty_state", {
743
761
  roomId: this.id,
@@ -1204,6 +1222,22 @@ export class Room extends EventEmitter {
1204
1222
  // omit cells until the app happens to repaint (for example, on scroll).
1205
1223
  // Begin tracking at TUI entry; dependency loading is shared and normally
1206
1224
  // finishes first, but the callback covers a TUI that starts immediately.
1225
+ /**
1226
+ * The escape sequences that put a fresh terminal into this app's current
1227
+ * modes: the alternate screen first (so the rest apply to it), then each
1228
+ * mode that is on, and a hidden cursor. "" for a plain shell.
1229
+ */
1230
+ modePrefix(): string {
1231
+ let alt = "", rest = "";
1232
+ for (const [mode, on] of this.decModes) {
1233
+ if (mode === "25") { if (!on) rest += "\x1b[?25l"; continue; }
1234
+ if (!on) continue;
1235
+ if (mode === "1049" || mode === "1047" || mode === "47") alt = `\x1b[?${mode}h`;
1236
+ else rest += `\x1b[?${mode}h`;
1237
+ }
1238
+ return alt + rest;
1239
+ }
1240
+
1207
1241
  private startTuiGrid() {
1208
1242
  if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
1209
1243
  if (!this.alternateScreen && !this.mouseReporting) return;
@@ -1244,7 +1278,11 @@ export class Room extends EventEmitter {
1244
1278
  const nl = tail.indexOf("\n");
1245
1279
  if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
1246
1280
  }
1247
- this.grid.write(tail);
1281
+ // The tail rarely reaches back to where the app set its modes
1282
+ // (an adopted room's saved prefix sits at the very front of the
1283
+ // ring): put the room's current modes first, or the grid serializes
1284
+ // a full-screen app as a plain shell. The tail's own changes win.
1285
+ this.grid.write(this.modePrefix() + tail);
1248
1286
  }
1249
1287
  }
1250
1288
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RoomManager } from "../src/rooms";
3
+ import type { PtyFactory } from "../src/pty";
4
+
5
+ class FakePty {
6
+ private handlers: Array<(data: string) => void> = [];
7
+ onData(handler: (data: string) => void) { this.handlers.push(handler); }
8
+ onExit() {}
9
+ emit(data: string) { for (const h of this.handlers) h(data); }
10
+ write() {}
11
+ resize() {}
12
+ kill() {}
13
+ }
14
+
15
+ describe("terminal modes a room remembers", () => {
16
+ it("a room reports the DEC modes its app set, alternate screen first", () => {
17
+ const pty = new FakePty();
18
+ const manager = new RoomManager((() => pty) as unknown as PtyFactory);
19
+ const room = manager.getRoom("codex", { cols: 80, rows: 24 }, "/tmp");
20
+ const seen: string[] = [];
21
+ room.on("modes", (p: string) => seen.push(p));
22
+ pty.emit("\x1b[?2004h\x1b[?1004h\x1b[?1049h\x1b[?1003h\x1b[?1006h\x1b[?25l hello");
23
+ expect(room.modePrefix()).toBe("\x1b[?1049h\x1b[?2004h\x1b[?1004h\x1b[?1003h\x1b[?1006h\x1b[?25l");
24
+ expect(seen.at(-1)).toBe(room.modePrefix());
25
+ pty.emit("\x1b[?1003l\x1b[?1049l");
26
+ expect(room.modePrefix()).toBe("\x1b[?2004h\x1b[?1004h\x1b[?1006h\x1b[?25l");
27
+ pty.emit("\x1bc");
28
+ expect(room.modePrefix()).toBe("");
29
+ });
30
+ });
@@ -49,6 +49,7 @@ import {
49
49
  } from "./utils/paneTree";
50
50
  import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
51
51
  import type { ViewsSummary } from "./utils/switcherModel";
52
+ import { isAppCtrlEnter, isFullscreenChord } from "./utils/fullscreenChord";
52
53
 
53
54
  const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
54
55
 
@@ -2329,13 +2330,21 @@ const App = () => {
2329
2330
  terminal.attachCustomKeyEventHandler((event) => {
2330
2331
  // ⌘⏎ leaves full screen for the wall — the mirror of ⌘⏎ on a wall
2331
2332
  // tile entering it. One chord toggles the mode from either side.
2332
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
2333
+ // On a Mac only ⌘⏎ — Ctrl⏎ goes to the app (see fullscreenChord.ts).
2334
+ if (isFullscreenChord(event)) {
2333
2335
  if (event.type === 'keydown') {
2334
2336
  event.preventDefault();
2335
2337
  setSwitcherOpen(true);
2336
2338
  }
2337
2339
  return false;
2338
2340
  }
2341
+ if (isAppCtrlEnter(event) && (remoteKbdEnhancedRef.current || foregroundIsClaude())) {
2342
+ if (event.type === 'keydown') {
2343
+ event.preventDefault();
2344
+ handleUserInput('\x1b[13;5u');
2345
+ }
2346
+ return false;
2347
+ }
2339
2348
  // Long-press SPACE = voice input — shared controller; see voiceHold.ts.
2340
2349
  {
2341
2350
  const verdict = voiceHoldRef.current?.handleKey(event);
@@ -37,6 +37,7 @@ import {
37
37
  type SwitcherSortMode,
38
38
  insertAfter, describeAccount, type AgentHome, type AgentHomes } from "../utils/switcherModel";
39
39
  import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
40
+ import { isAppCtrlEnter, isFullscreenChord } from "../utils/fullscreenChord";
40
41
  import { DigestCard } from "./DigestCard";
41
42
  import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
42
43
 
@@ -693,10 +694,15 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
693
694
  if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;2u");
694
695
  return false;
695
696
  }
696
- if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey && ev.key === "Enter") {
697
+ // On a Mac only ⌘⏎ — Ctrl⏎ goes to the app (see fullscreenChord.ts).
698
+ if (isFullscreenChord(ev)) {
697
699
  if (ev.type === "keydown") onFullscreenRef.current();
698
700
  return false;
699
701
  }
702
+ if (isAppCtrlEnter(ev) && (kbdEnhancedRef.current || looksLikeClaude())) {
703
+ if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;5u");
704
+ return false;
705
+ }
700
706
  if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey && ev.key === ".") {
701
707
  if (ev.type === "keydown") onUnfocusRef.current();
702
708
  return false;
@@ -0,0 +1,18 @@
1
+ // The full-screen ⇄ wall chord inside a terminal. On a Mac it is ⌘⏎ only:
2
+ // Ctrl⏎ belongs to the app (Claude Code binds it), and hop taking it meant
3
+ // the keystroke never arrived. Elsewhere there is no ⌘, so Ctrl⏎ stays.
4
+ export const IS_MAC = typeof navigator !== "undefined"
5
+ && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "");
6
+
7
+ type Chord = { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean };
8
+
9
+ export function isFullscreenChord(ev: Chord, mac = IS_MAC): boolean {
10
+ if (ev.key !== "Enter" || ev.altKey || ev.shiftKey) return false;
11
+ return mac ? ev.metaKey && !ev.ctrlKey : ev.metaKey || ev.ctrlKey;
12
+ }
13
+
14
+ /** Ctrl⏎ passed to the app: CSI-u (13;5u), since xterm.js would send a bare \r. */
15
+ export function isAppCtrlEnter(ev: Chord, mac = IS_MAC): boolean {
16
+ return ev.key === "Enter" && ev.ctrlKey && !ev.metaKey && !ev.altKey && !ev.shiftKey
17
+ && !isFullscreenChord(ev, mac);
18
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isAppCtrlEnter, isFullscreenChord } from "../src/utils/fullscreenChord";
3
+
4
+ const key = (mods: Partial<{ metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }>, k = "Enter") =>
5
+ ({ key: k, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...mods });
6
+
7
+ describe("full-screen chord", () => {
8
+ it("on a Mac only ⌘⏎ toggles; Ctrl⏎ is the app's", () => {
9
+ expect(isFullscreenChord(key({ metaKey: true }), true)).toBe(true);
10
+ expect(isFullscreenChord(key({ ctrlKey: true }), true)).toBe(false);
11
+ expect(isAppCtrlEnter(key({ ctrlKey: true }), true)).toBe(true);
12
+ });
13
+ it("without ⌘ (Windows/Linux) Ctrl⏎ still toggles", () => {
14
+ expect(isFullscreenChord(key({ ctrlKey: true }), false)).toBe(true);
15
+ expect(isAppCtrlEnter(key({ ctrlKey: true }), false)).toBe(false);
16
+ });
17
+ it("other chords are untouched", () => {
18
+ expect(isFullscreenChord(key({ metaKey: true, shiftKey: true }), true)).toBe(false);
19
+ expect(isFullscreenChord(key({}), true)).toBe(false);
20
+ expect(isAppCtrlEnter(key({ ctrlKey: true }, "a"), true)).toBe(false);
21
+ });
22
+ });
package/hop CHANGED
@@ -802,7 +802,18 @@ async function withLifecycleLock(op, fn, { waitMs = 120_000, onWait } = {}) {
802
802
  }
803
803
  // Marker so the authenticator setup QR is only shown on first run (UX only; no auth logic).
804
804
  const AUTH_QR_SHOWN_FILE = path.join(HOME_DIR, '.auth_qr_shown');
805
- const HAY_HOST_READY_TIMEOUT_MS = parsePositiveIntEnv('HOP_HAY_HOST_READY_TIMEOUT_MS', 5000);
805
+ // A keeper-backed host re-adopts every running terminal (replaying each
806
+ // screen) BEFORE it opens its port. With 30 rooms that took ~8 s, then ~41 s
807
+ // once one replay timed out (2026-10-12), so first a 5 s and then a 25 s
808
+ // limit killed each new host mid-adoption and spawned the next — a restart
809
+ // loop that left hop with no host, and took the daemon down with it ("Fatal:
810
+ // Hay host did not open its port in time") while a `hop restore` was in
811
+ // flight, which the CLI reported as "socket hang up". This is now a ceiling,
812
+ // not the expected wait: the spawner keeps waiting while the host process is
813
+ // alive (see waitForHayHostPort), gives up early only once it has died, and
814
+ // refreshes the spawn lock as it waits so a rival spawner never mistakes a
815
+ // long adoption for a stale lock.
816
+ const HAY_HOST_READY_TIMEOUT_MS = parsePositiveIntEnv('HOP_HAY_HOST_READY_TIMEOUT_MS', 180000);
806
817
  const HAY_HOST_DISCOVERY_CACHE_MS = parsePositiveIntEnv('HOP_HAY_HOST_DISCOVERY_CACHE_MS', 10000);
807
818
  const HAY_HOST_HOME_HASH = crypto.createHash('sha256').update(path.resolve(HOME_DIR)).digest('hex').slice(0, 24);
808
819
  const HAY_HOST_HOME_FLAG = `--hop-home-hash=${HAY_HOST_HOME_HASH}`;
@@ -9706,6 +9717,27 @@ async function stopExternalHayHostProcess(hostState, timeoutMs = 1500) {
9706
9717
  return !isProcessAlive(pid);
9707
9718
  }
9708
9719
 
9720
+ // Wait for a freshly spawned host to open its port. Adopting a big fleet from
9721
+ // the pty-keeper can take a minute or more, so the wait is bounded by the
9722
+ // host's LIFE first and the ceiling second: a host that dies returns at once
9723
+ // instead of burning the whole ceiling, and one still working keeps the spawn
9724
+ // lock's mtime fresh so a concurrent spawner (30 s staleness rule) never calls
9725
+ // the lock stale and starts a rival host mid-adoption.
9726
+ async function waitForHayHostPort(started, ceilingMs) {
9727
+ const deadline = Date.now() + ceilingMs;
9728
+ let lastLockTouch = 0;
9729
+ while (Date.now() < deadline) {
9730
+ if (await waitForPort(started.port, '127.0.0.1', 1000, 120)) return true;
9731
+ if (!isProcessAlive(started.pid)) return false;
9732
+ if (Date.now() - lastLockTouch > 5000) {
9733
+ lastLockTouch = Date.now();
9734
+ const now = new Date();
9735
+ try { fs.utimesSync(HAY_HOST_SPAWN_LOCK, now, now); } catch (e) { /* lock gone — nothing to refresh */ }
9736
+ }
9737
+ }
9738
+ return false;
9739
+ }
9740
+
9709
9741
  function markExternalHayHostResponsive(host) {
9710
9742
  const recovered = hayHostProbeFailures.recordSuccess(Number(host?.pid));
9711
9743
  if (recovered) {
@@ -9832,6 +9864,9 @@ async function ensureExternalHayHostReadyOnce() {
9832
9864
  if (spawned && isProcessAlive(spawned.pid) && await probeExternalHayHostFromHost(spawned, 1500)) {
9833
9865
  return markExternalHayHostResponsive(spawned);
9834
9866
  }
9867
+ // The winner releases the lock on success and failure alike;
9868
+ // once it is gone there is nothing left to wait for.
9869
+ if (!fs.existsSync(HAY_HOST_SPAWN_LOCK)) break;
9835
9870
  }
9836
9871
  // Winner never delivered — fall through and spawn ourselves.
9837
9872
  }
@@ -9917,10 +9952,13 @@ async function ensureExternalHayHostReadyOnce() {
9917
9952
  throw new Error(`Hay host failed to start; see ${hayHostLogFile}`);
9918
9953
  }
9919
9954
 
9920
- const ready = await waitForPort(started.port, '127.0.0.1', HAY_HOST_READY_TIMEOUT_MS, 120);
9955
+ const ready = await waitForHayHostPort(started, HAY_HOST_READY_TIMEOUT_MS);
9921
9956
  if (!ready) {
9957
+ const died = !isProcessAlive(started.pid);
9922
9958
  await stopExternalHayHostProcess(started);
9923
- throw new Error(`Hay host did not open its port in time; see ${hayHostLogFile}`);
9959
+ throw new Error(died
9960
+ ? `Hay host (pid ${started.pid}) exited before opening its port; see ${hayHostLogFile}`
9961
+ : `Hay host did not open its port within ${Math.round(HAY_HOST_READY_TIMEOUT_MS / 1000)}s (still adopting its terminals? HOP_HAY_HOST_READY_TIMEOUT_MS raises the ceiling); see ${hayHostLogFile}`);
9924
9962
  }
9925
9963
  const healthy = await probeExternalHayHostFromHost(started, 1500);
9926
9964
  if (!healthy) {
@@ -17559,7 +17597,13 @@ async function main() {
17559
17597
  try {
17560
17598
  res = await callLocalApi(state, '/api/sessions/restore', 'POST', { dryRun, includeAgents, only: only.length ? only : undefined });
17561
17599
  } catch (e) {
17562
- log(`Restore request failed: ${e.message}`, RED);
17600
+ // "socket hang up" here means the daemon closed the connection
17601
+ // without answering: it was restarted, or died (e.g. its host
17602
+ // never came up), while the restore was in flight. Nothing about
17603
+ // the restore itself failed, so say what to do next.
17604
+ const gone = !isServerRunning();
17605
+ log(`Restore request failed: ${e.message}${gone ? ' — the daemon went away mid-request' : ''}.`, RED);
17606
+ log(` ${DIM}Check ${CYAN}hop logs${RESET}${DIM} (look for "Fatal:" or "Cleaning up"), then run ${CYAN}hop restore${RESET}${DIM} again — it only recreates sessions that are not running.${RESET}`, RESET);
17563
17607
  process.exit(1);
17564
17608
  }
17565
17609
  if (!res || res.status < 200 || res.status >= 300) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.76",
3
+ "version": "0.11.77",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -91,10 +91,19 @@ function loadViewerColors(hay) {
91
91
  if (typeof hay.setDefaultViewerColors === 'function') hay.setDefaultViewerColors(saved.bg, saved.fg);
92
92
  } catch (e) { /* none yet: the dark default */ }
93
93
  }
94
+ // Compared against what is on disk, not setDefaultViewerColors' "changed":
95
+ // room.setClientColors already updates the defaults before this runs, so
96
+ // that always said "unchanged" and the file was never written — every host
97
+ // restart went back to the dark default (Codex's charcoal composer).
98
+ let savedViewerColors = null;
94
99
  function rememberViewerColors(hay, bg, fg) {
95
100
  if (typeof hay.setDefaultViewerColors !== 'function') return;
96
- if (!hay.setDefaultViewerColors(bg, fg)) return;
97
- try { fs.writeFileSync(VIEWER_COLORS_FILE, JSON.stringify(hay.getDefaultViewerColors())); } catch (e) { /* best effort */ }
101
+ hay.setDefaultViewerColors(bg, fg);
102
+ const json = JSON.stringify(hay.getDefaultViewerColors());
103
+ if (savedViewerColors === null) { try { savedViewerColors = fs.readFileSync(VIEWER_COLORS_FILE, 'utf8'); } catch (e) { savedViewerColors = ''; } }
104
+ if (json === savedViewerColors) return;
105
+ try { fs.writeFileSync(VIEWER_COLORS_FILE, json); savedViewerColors = json; } catch (e) { /* best effort */ }
106
+ }
98
107
  const CLAUDE_SESSIONS_DIR = path.join(HOP_HOME, 'claude-sessions');
99
108
 
100
109
  // Scrub Claude Code SESSION markers from our environment before any PTY is
@@ -153,9 +162,29 @@ const watchedRooms = new WeakSet();
153
162
  // restore <name>`): their records must survive the close, exactly as they
154
163
  // survive a host shutdown. Consumed once, on the next session_end.
155
164
  const preserveRecordsOnClose = new Set();
165
+ // Each room's terminal modes (Room.modePrefix), saved as they change so a
166
+ // re-adopted room starts in them: an app turns alternate screen and mouse
167
+ // reporting on once, at startup, which a bounded replay no longer holds.
168
+ const ROOM_MODES_DIR = path.join(HOP_HOME, 'room-modes');
169
+ const roomModesFile = (id) => path.join(ROOM_MODES_DIR, `${id}.modes`);
170
+ function loadRoomModes(id) {
171
+ if (!/^[A-Za-z0-9_.-]+$/.test(String(id || ''))) return '';
172
+ try { return fs.readFileSync(roomModesFile(id), 'utf8'); } catch (e) { return ''; }
173
+ }
174
+ function saveRoomModes(id, prefix) {
175
+ try {
176
+ if (!prefix) { fs.unlinkSync(roomModesFile(id)); return; }
177
+ fs.mkdirSync(ROOM_MODES_DIR, { recursive: true });
178
+ fs.writeFileSync(roomModesFile(id), prefix);
179
+ } catch (e) { /* best effort */ }
180
+ }
156
181
  function watchRoomEnd(room) {
157
182
  if (!room || typeof room.on !== 'function' || watchedRooms.has(room)) return room;
158
183
  watchedRooms.add(room);
184
+ if (/^[A-Za-z0-9_.-]+$/.test(String(room.id || ''))) {
185
+ room.on('modes', (prefix) => saveRoomModes(String(room.id), String(prefix || '')));
186
+ if (typeof room.modePrefix === 'function') saveRoomModes(String(room.id), room.modePrefix());
187
+ }
159
188
  room.on('session_end', () => {
160
189
  if (shuttingDown) return;
161
190
  const id = String(room.id || '');
@@ -168,6 +197,7 @@ function watchRoomEnd(room) {
168
197
  // request to forget its conversation; that is an explicit delete's
169
198
  // job, daemon-side.
170
199
  try { fs.unlinkSync(path.join(BUFFER_DIR, `${id}.raw`)); } catch (e) { /* best effort */ }
200
+ try { fs.unlinkSync(roomModesFile(id)); } catch (e) { /* best effort */ }
171
201
  });
172
202
  return room;
173
203
  }