hop2 0.11.76 → 0.11.78

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,18 @@ 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
+ // Output batching (see Room.queueOutput). HAY_OUTPUT_BATCH_MS=0 turns it off.
146
+ const OUTPUT_BATCH_MS = (() => {
147
+ const env = Number(process.env.HAY_OUTPUT_BATCH_MS);
148
+ return Number.isFinite(env) && env >= 0 ? env : 2;
149
+ })();
150
+ const OUTPUT_BATCH_MAX_MS = 16;
151
+
152
+ // DEC private modes worth restoring on adopt: cursor keys, mouse (any
153
+ // flavour and encoding), focus events, alternate screen, bracketed paste,
154
+ // cursor visibility.
155
+ const TRACKED_DEC_MODES = new Set(["1", "25", "47", "1000", "1002", "1003", "1004", "1005", "1006", "1015", "1047", "1049", "2004"]);
156
+
145
157
  const MAX_BUFFER_SIZE = (() => {
146
158
  const env = Number(process.env.HAY_SNAPSHOT_BUFFER_BYTES);
147
159
  return Number.isFinite(env) && env > 0 ? env : 6_000_000;
@@ -321,6 +333,13 @@ export class Room extends EventEmitter {
321
333
  private mouseReporting = false;
322
334
  private mouseSgr = false;
323
335
  private cursorHidden = false;
336
+ // The DEC private modes an app set (alternate screen, mouse, focus,
337
+ // bracketed paste…), exactly. An app sets them once at startup; a host
338
+ // restart rebuilds the room from a bounded replay that no longer holds
339
+ // that moment, so the room forgot Codex was full-screen and the wheel
340
+ // scrolled the page instead of the app (2026-10-12). The host saves
341
+ // modePrefix() and puts it back in front of the replay on adopt.
342
+ private decModes = new Map<string, boolean>();
324
343
  private controlSequenceTail = "";
325
344
  private oscBuffer = "";
326
345
  private cleanupTimer: NodeJS.Timeout | null = null;
@@ -373,7 +392,7 @@ export class Room extends EventEmitter {
373
392
  this.updateTerminalState(data);
374
393
  this.startTuiGrid();
375
394
  this.answerTerminalQueriesIfHeadless(data);
376
- this.broadcast({ type: "output", data });
395
+ this.queueOutput(data);
377
396
  this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
378
397
  });
379
398
 
@@ -428,6 +447,9 @@ export class Room extends EventEmitter {
428
447
  }
429
448
 
430
449
  attachClient(info: ClientInfo, socket: SocketAdapter) {
450
+ // Output still waiting to go out belongs to the viewers already here;
451
+ // the newcomer's snapshot already contains it.
452
+ this.flushOutput();
431
453
  if (this.cleanupTimer) {
432
454
  clearTimeout(this.cleanupTimer);
433
455
  this.cleanupTimer = null;
@@ -569,6 +591,9 @@ export class Room extends EventEmitter {
569
591
  // Catch-up: output that arrived while the snapshot was being
570
592
  // serialized. Appended INSIDE the snapshot payload so snapshot and
571
593
  // delta land as one atomic parse — no frame between reset and delta.
594
+ // Batched output not yet sent is behind the serialize marker too:
595
+ // flushing now routes it into this client's catch-up.
596
+ this.flushOutput();
572
597
  const catchUp = client.snapshotCatchUp.join("");
573
598
  socket.send(JSON.stringify({
574
599
  type: "snapshot",
@@ -678,10 +703,13 @@ export class Room extends EventEmitter {
678
703
 
679
704
  let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
680
705
  let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
706
+ const modesBefore = this.modePrefix();
707
+ if (hasNewRis) this.decModes.clear();
681
708
  while ((match = regex.exec(stateInput)) !== null) {
682
709
  const params = match[1].split(";").filter(Boolean);
683
710
  const mode = match[2];
684
711
  for (const param of params) {
712
+ if (TRACKED_DEC_MODES.has(param)) this.decModes.set(param, mode === "h");
685
713
  if (param === "25") {
686
714
  nextCursorHidden = mode === "l";
687
715
  } else if (param === "47" || param === "1047" || param === "1049") {
@@ -738,6 +766,9 @@ export class Room extends EventEmitter {
738
766
  stateChanged = true;
739
767
  }
740
768
 
769
+ const modesAfter = this.modePrefix();
770
+ if (modesAfter !== modesBefore) this.emit("modes", modesAfter);
771
+
741
772
  if (stateChanged) {
742
773
  this.emit("pty_state", {
743
774
  roomId: this.id,
@@ -1057,6 +1088,7 @@ export class Room extends EventEmitter {
1057
1088
  }
1058
1089
 
1059
1090
  private sendActiveSize(client: ClientState, ownerId: string) {
1091
+ this.flushOutput();
1060
1092
  client.socket.send(
1061
1093
  JSON.stringify({
1062
1094
  type: "active_size",
@@ -1068,6 +1100,7 @@ export class Room extends EventEmitter {
1068
1100
  }
1069
1101
 
1070
1102
  private broadcastActiveSize(client: ClientState) {
1103
+ this.flushOutput();
1071
1104
  // Broadcast to OTHER clients (not the one typing)
1072
1105
  const payload = JSON.stringify({
1073
1106
  type: "active_size",
@@ -1082,7 +1115,42 @@ export class Room extends EventEmitter {
1082
1115
  }
1083
1116
  }
1084
1117
 
1118
+ /**
1119
+ * Live output is sent in screen-sized messages, not one per pty read:
1120
+ * Codex writes a redraw in ~10 pieces, so one wheel tick was ten
1121
+ * WebSocket messages through the tunnel (321 for 30 ticks). Pieces that
1122
+ * arrive within OUTPUT_BATCH_MS of each other go out together, capped at
1123
+ * OUTPUT_BATCH_MAX_MS after the first so a steady stream still flows.
1124
+ */
1125
+ private pendingOutput: string[] = [];
1126
+ private pendingOutputSince = 0;
1127
+ private outputFlushTimer: NodeJS.Timeout | null = null;
1128
+
1129
+ private queueOutput(data: string) {
1130
+ if (OUTPUT_BATCH_MS <= 0) { this.broadcast({ type: "output", data }); return; }
1131
+ const t = now();
1132
+ if (this.pendingOutput.length === 0) this.pendingOutputSince = t;
1133
+ this.pendingOutput.push(data);
1134
+ if (this.outputFlushTimer) clearTimeout(this.outputFlushTimer);
1135
+ if (t - this.pendingOutputSince >= OUTPUT_BATCH_MAX_MS) { this.flushOutput(); return; }
1136
+ this.outputFlushTimer = setTimeout(() => this.flushOutput(), OUTPUT_BATCH_MS);
1137
+ }
1138
+
1139
+ private flushOutput() {
1140
+ if (this.outputFlushTimer) { clearTimeout(this.outputFlushTimer); this.outputFlushTimer = null; }
1141
+ if (this.pendingOutput.length === 0) return;
1142
+ const data = this.pendingOutput.length === 1 ? this.pendingOutput[0] : this.pendingOutput.join("");
1143
+ this.pendingOutput = [];
1144
+ this.sendToClients({ type: "output", data });
1145
+ }
1146
+
1085
1147
  private broadcast(message: ServerMessage) {
1148
+ // Anything else keeps its place after the output that preceded it.
1149
+ if ((message as { type?: string }).type !== "output") this.flushOutput();
1150
+ this.sendToClients(message);
1151
+ }
1152
+
1153
+ private sendToClients(message: ServerMessage) {
1086
1154
  const payload = JSON.stringify(message);
1087
1155
  const isOutput = (message as { type?: string }).type === "output";
1088
1156
  for (const client of this.clients.values()) {
@@ -1204,6 +1272,22 @@ export class Room extends EventEmitter {
1204
1272
  // omit cells until the app happens to repaint (for example, on scroll).
1205
1273
  // Begin tracking at TUI entry; dependency loading is shared and normally
1206
1274
  // finishes first, but the callback covers a TUI that starts immediately.
1275
+ /**
1276
+ * The escape sequences that put a fresh terminal into this app's current
1277
+ * modes: the alternate screen first (so the rest apply to it), then each
1278
+ * mode that is on, and a hidden cursor. "" for a plain shell.
1279
+ */
1280
+ modePrefix(): string {
1281
+ let alt = "", rest = "";
1282
+ for (const [mode, on] of this.decModes) {
1283
+ if (mode === "25") { if (!on) rest += "\x1b[?25l"; continue; }
1284
+ if (!on) continue;
1285
+ if (mode === "1049" || mode === "1047" || mode === "47") alt = `\x1b[?${mode}h`;
1286
+ else rest += `\x1b[?${mode}h`;
1287
+ }
1288
+ return alt + rest;
1289
+ }
1290
+
1207
1291
  private startTuiGrid() {
1208
1292
  if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
1209
1293
  if (!this.alternateScreen && !this.mouseReporting) return;
@@ -1244,7 +1328,11 @@ export class Room extends EventEmitter {
1244
1328
  const nl = tail.indexOf("\n");
1245
1329
  if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
1246
1330
  }
1247
- this.grid.write(tail);
1331
+ // The tail rarely reaches back to where the app set its modes
1332
+ // (an adopted room's saved prefix sits at the very front of the
1333
+ // ring): put the room's current modes first, or the grid serializes
1334
+ // a full-screen app as a plain shell. The tail's own changes win.
1335
+ this.grid.write(this.modePrefix() + tail);
1248
1336
  }
1249
1337
  }
1250
1338
  }
@@ -0,0 +1,67 @@
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
+ class FakeSocket {
15
+ messages: Array<{ type: string; [k: string]: unknown }> = [];
16
+ send(data: string) { this.messages.push(JSON.parse(data)); }
17
+ onMessage() {}
18
+ onClose() {}
19
+ onError() {}
20
+ isOpen() { return true; }
21
+ close() {}
22
+ }
23
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
24
+
25
+ describe("live output batching", () => {
26
+ it("pieces of one redraw go out as one message, in order", async () => {
27
+ const pty = new FakePty();
28
+ const room = new RoomManager((() => pty) as unknown as PtyFactory).getRoom("b", { cols: 80, rows: 24 }, "/tmp");
29
+ const sock = new FakeSocket();
30
+ room.attachClient({ id: "a", name: "A", colorIndex: 0, cols: 80, rows: 24, nudge: false }, sock as never);
31
+ await sleep(50);
32
+ sock.messages = [];
33
+ for (const piece of ["\x1b[H", "row one\r\n", "row two\r\n", "\x1b[?25h"]) pty.emit(piece);
34
+ await sleep(30);
35
+ const out = sock.messages.filter((m) => m.type === "output");
36
+ expect(out).toHaveLength(1);
37
+ expect(out[0].data).toBe("\x1b[Hrow one\r\nrow two\r\n\x1b[?25h");
38
+ });
39
+
40
+ it("a steady stream still flows (capped wait)", async () => {
41
+ const pty = new FakePty();
42
+ const room = new RoomManager((() => pty) as unknown as PtyFactory).getRoom("s", { cols: 80, rows: 24 }, "/tmp");
43
+ const sock = new FakeSocket();
44
+ room.attachClient({ id: "a", name: "A", colorIndex: 0, cols: 80, rows: 24, nudge: false }, sock as never);
45
+ await sleep(50);
46
+ sock.messages = [];
47
+ const t0 = Date.now();
48
+ while (Date.now() - t0 < 60) { pty.emit("x"); await sleep(1); }
49
+ // Without the cap, pieces 1 ms apart would be held until the stream stopped.
50
+ expect(sock.messages.filter((m) => m.type === "output").length).toBeGreaterThan(1);
51
+ });
52
+
53
+ it("output pending when a second viewer attaches reaches the first, and isn't repeated to the second", async () => {
54
+ const pty = new FakePty();
55
+ const room = new RoomManager((() => pty) as unknown as PtyFactory).getRoom("j", { cols: 80, rows: 24 }, "/tmp");
56
+ const first = new FakeSocket();
57
+ room.attachClient({ id: "a", name: "A", colorIndex: 0, cols: 80, rows: 24, nudge: false }, first as never);
58
+ await sleep(50);
59
+ first.messages = [];
60
+ pty.emit("PENDING-BEFORE-JOIN");
61
+ const second = new FakeSocket();
62
+ room.attachClient({ id: "b", name: "B", colorIndex: 1, cols: 80, rows: 24, nudge: false }, second as never);
63
+ expect(first.messages.some((m) => m.type === "output" && String(m.data).includes("PENDING-BEFORE-JOIN"))).toBe(true);
64
+ await sleep(50);
65
+ expect(second.messages.some((m) => m.type === "output" && String(m.data).includes("PENDING-BEFORE-JOIN"))).toBe(false);
66
+ });
67
+ });
@@ -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
+ });
@@ -146,7 +146,7 @@ describe("Room", () => {
146
146
  expect(readMessages(phone).some((m) => m.type === "active_size" && m.clientId === "phone")).toBe(true);
147
147
  });
148
148
 
149
- it("broadcasts pty output to all clients", () => {
149
+ it("broadcasts pty output to all clients", async () => {
150
150
  let ptyInstance: FakePty | null = null;
151
151
  const factory: PtyFactory = () => {
152
152
  ptyInstance = new FakePty() as unknown as FakePty;
@@ -162,6 +162,7 @@ describe("Room", () => {
162
162
  room.attachClient({ id: "b", name: "Blake", colorIndex: 1, cols: 80, rows: 24 }, socketB);
163
163
 
164
164
  ptyInstance?.emit("hello");
165
+ await new Promise((r) => setTimeout(r, 20)); // output is batched (Room.queueOutput)
165
166
 
166
167
  const outputsA = findMessages(socketA, "output");
167
168
  const outputsB = findMessages(socketB, "output");
@@ -962,6 +963,7 @@ describe("Room", () => {
962
963
  expect(String(snapshot.data)).toContain("LATE-MARKER-XYZ");
963
964
  // After the snapshot the stream flows live again, no duplication.
964
965
  pty().emit("AFTER-MARKER\r\n");
966
+ await new Promise((r) => setTimeout(r, 20)); // output is batched
965
967
  const after = readMessages(socket).filter((m) => m.type === "output");
966
968
  expect(after.some((m) => String(m.data).includes("AFTER-MARKER"))).toBe(true);
967
969
  expect(after.some((m) => String(m.data).includes("LATE-MARKER-XYZ"))).toBe(false);
@@ -49,6 +49,8 @@ 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";
53
+ import { isLocalLinkUrl, localLinkFailed, localWsBase, startLocalLink } from "./utils/localLink";
52
54
 
53
55
  const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
54
56
 
@@ -87,6 +89,9 @@ const getLocationRoom = () => {
87
89
  const buildSessionPath = (sessionName: string) => `/s/${encodeURIComponent(sessionName)}/`;
88
90
 
89
91
  const resolveWsUrl = () => {
92
+ // On the machine hop runs on, terminal sockets skip the tunnel.
93
+ const local = localWsBase();
94
+ if (local) return local;
90
95
  // Check for hop session config (when embedded in hop)
91
96
  const hopSession = (window as unknown as { __HOP_SESSION__?: { wsUrl?: string } }).__HOP_SESSION__;
92
97
  if (hopSession?.wsUrl) {
@@ -1760,7 +1765,7 @@ const App = () => {
1760
1765
  // dark) then match the screen instead of a guess.
1761
1766
  const surface = resolveTerminalTheme(themeMode);
1762
1767
  const hex = (c?: string) => (c || "").replace("#", "").slice(0, 6);
1763
- const url = `${wsUrl}?room=${encodeURIComponent(nextSession.room)}&name=${encodeURIComponent(
1768
+ const url = `${wsUrl}${wsUrl.includes("?") ? "&" : "?"}room=${encodeURIComponent(nextSession.room)}&name=${encodeURIComponent(
1764
1769
  nextSession.name
1765
1770
  )}&cols=${cols}&rows=${rows}&replay=${replay}&bg=${hex(surface.background)}&fg=${hex(surface.foreground)}&device=${encodeURIComponent(resolveDeviceKey())}`;
1766
1771
 
@@ -1770,6 +1775,9 @@ const App = () => {
1770
1775
 
1771
1776
  const ws = new WebSocket(url);
1772
1777
  wsRef.current = ws;
1778
+ const viaLocalLink = isLocalLinkUrl(url);
1779
+ let opened = false;
1780
+ ws.addEventListener("open", () => { opened = true; });
1773
1781
  // When this socket began CONNECTING — the watchdog uses it to catch a
1774
1782
  // handshake that hangs (accepted TCP, WS upgrade never completes through
1775
1783
  // the tunnel): it fires no open/close/error, so nothing else can notice.
@@ -2042,6 +2050,9 @@ const App = () => {
2042
2050
  });
2043
2051
 
2044
2052
  ws.addEventListener("close", () => {
2053
+ // The fast path refused or vanished: fall back to the tunnel, which
2054
+ // the reconnect below then uses.
2055
+ if (viaLocalLink && !opened) localLinkFailed();
2045
2056
  if (connectionNonce !== connectNonceRef.current) {
2046
2057
  return;
2047
2058
  }
@@ -2069,6 +2080,11 @@ const App = () => {
2069
2080
  });
2070
2081
  };
2071
2082
 
2083
+ // Once the same-machine fast path answers, reconnect onto it.
2084
+ useEffect(() => {
2085
+ startLocalLink(() => setReconnectToken((value) => value + 1));
2086
+ }, []);
2087
+
2072
2088
  useEffect(() => {
2073
2089
  if (!session || !terminalReady) {
2074
2090
  return;
@@ -2329,13 +2345,21 @@ const App = () => {
2329
2345
  terminal.attachCustomKeyEventHandler((event) => {
2330
2346
  // ⌘⏎ leaves full screen for the wall — the mirror of ⌘⏎ on a wall
2331
2347
  // tile entering it. One chord toggles the mode from either side.
2332
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
2348
+ // On a Mac only ⌘⏎ — Ctrl⏎ goes to the app (see fullscreenChord.ts).
2349
+ if (isFullscreenChord(event)) {
2333
2350
  if (event.type === 'keydown') {
2334
2351
  event.preventDefault();
2335
2352
  setSwitcherOpen(true);
2336
2353
  }
2337
2354
  return false;
2338
2355
  }
2356
+ if (isAppCtrlEnter(event) && (remoteKbdEnhancedRef.current || foregroundIsClaude())) {
2357
+ if (event.type === 'keydown') {
2358
+ event.preventDefault();
2359
+ handleUserInput('\x1b[13;5u');
2360
+ }
2361
+ return false;
2362
+ }
2339
2363
  // Long-press SPACE = voice input — shared controller; see voiceHold.ts.
2340
2364
  {
2341
2365
  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;
@@ -2584,12 +2590,12 @@ export const SessionSwitcher = ({
2584
2590
 
2585
2591
  const forkSession = async (s: SwitcherSession, target?: "claude" | "codex") => {
2586
2592
  setSheet(null);
2587
- onNotice(target ? `Handing off to ${target === "codex" ? "Codex" : "Claude"}…` : "Forking…");
2593
+ onNotice(target ? `Condensing for ${target === "codex" ? "Codex" : "Claude"}…` : "Forking…");
2588
2594
  try {
2589
2595
  const res = await fetch("/api/sessions/fork", {
2590
2596
  method: "POST",
2591
2597
  headers: { "Content-Type": "application/json" },
2592
- body: JSON.stringify({ internalName: sessionKey(s), ...(target ? { target } : {}) })
2598
+ body: JSON.stringify({ internalName: sessionKey(s), ...(target ? { target, condense: true } : {}) })
2593
2599
  });
2594
2600
  const data = await res.json().catch(() => ({} as { error?: string; name?: string; internalName?: string; kind?: string; extract?: { turns?: number; bytes?: number } }));
2595
2601
  if (!res.ok) {
@@ -3755,21 +3761,19 @@ export const SessionSwitcher = ({
3755
3761
  <button type="button" onClick={() => { void forkSession(sheetSession); }}>
3756
3762
  Fork session
3757
3763
  </button>
3758
- {/* Crossing tools: the conversation is extracted to a document
3759
- the new agent reads first (context handoff, not a resume).
3760
- Offered for every terminal — the daemon knows what a
3761
- restored session runs better than the process name here —
3762
- minus the tool this one is visibly running already. */}
3763
- {sheetSession.type !== "port" && appLabel(sheetSession) !== "codex" && (
3764
- <button type="button" onClick={() => { void forkSession(sheetSession, "codex"); }}>
3765
- Continue in Codex…
3766
- </button>
3767
- )}
3768
- {sheetSession.type !== "port" && appLabel(sheetSession) !== "claude" && (
3769
- <button type="button" onClick={() => { void forkSession(sheetSession, "claude"); }}>
3770
- Continue in Claude…
3764
+ {/* Continue in an agent: the conversation is condensed to a
3765
+ document the new agent reads first (a context handoff, not
3766
+ a resume). Into the OTHER tool that is the only way across;
3767
+ into the SAME tool it is a fresh start without the full,
3768
+ context-heavy history. Offered for every terminal — the
3769
+ daemon knows what a restored session runs better than the
3770
+ process name here. */}
3771
+ {sheetSession.type !== "port" && (["claude", "codex"] as const).map((target) => (
3772
+ <button key={target} type="button" onClick={() => { void forkSession(sheetSession, target); }}>
3773
+ {appLabel(sheetSession) === target ? "Continue in fresh " : "Continue in "}
3774
+ {target === "codex" ? "Codex" : "Claude"}…
3771
3775
  </button>
3772
- )}
3776
+ ))}
3773
3777
  <button
3774
3778
  type="button"
3775
3779
  onClick={() => {
@@ -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,101 @@
1
+ // Same-machine fast path. On the Mac hop runs on, a page opened through the
2
+ // public hostname still sent every keystroke and scroll tick through
3
+ // Cloudflare and back (70–240 ms, spikes past 1 s) to a daemon a
4
+ // millisecond away. The daemon hands this login a ticket over the tunnel
5
+ // (/api/local-link); if a WebSocket to 127.0.0.1 accepts it, terminal
6
+ // sockets attach there instead. Everything else (API calls, the page itself)
7
+ // stays on the public hostname. Anywhere but that Mac the probe fails and
8
+ // nothing changes.
9
+
10
+ // Chrome asks once before a public page may reach this machine ("apps and
11
+ // services on this device"); the probe waits while that prompt is up. It runs
12
+ // beside the tunnel connection, so waiting costs nothing.
13
+ const PROBE_TIMEOUT_MS = 120_000;
14
+ const RETRY_AFTER_FAILURE_MS = 30_000;
15
+ const OPT_OUT_KEY = "hop.localLink"; // "off" disables the fast path in this browser
16
+
17
+ let localBase: string | null = null;
18
+ let probing = false;
19
+ let retryTimer = 0;
20
+ let onFound: (() => void) | null = null;
21
+
22
+ export const isLocalLinkUrl = (url: string) => /^ws:\/\/127\.0\.0\.1:\d+\//.test(url);
23
+
24
+ // The ws base terminal sockets should use instead of the page's own host,
25
+ // or null to use the page's host.
26
+ export const localWsBase = () => localBase;
27
+
28
+ export function localLinkEligible(
29
+ loc: { protocol: string; hostname: string } = window.location,
30
+ ua: string = navigator.userAgent,
31
+ touchPoints: number = navigator.maxTouchPoints || 0
32
+ ) {
33
+ if (loc.protocol !== "https:") return false; // already local, or a dev server
34
+ if (/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(loc.hostname)) return false;
35
+ // Phones and tablets are never the machine hop runs on (iPadOS reports
36
+ // itself as a Mac, but with touch points).
37
+ if (/iPhone|iPad|Android|Mobile/i.test(ua)) return false;
38
+ if (/Macintosh/.test(ua) && touchPoints > 1) return false;
39
+ try { if (localStorage.getItem(OPT_OUT_KEY) === "off") return false; } catch { /* storage blocked */ }
40
+ return true;
41
+ }
42
+
43
+ function probe(port: number, ticket: string): Promise<boolean> {
44
+ return new Promise((resolve) => {
45
+ let done = false;
46
+ let sock: WebSocket;
47
+ const finish = (ok: boolean) => {
48
+ if (done) return;
49
+ done = true;
50
+ window.clearTimeout(timer);
51
+ try { sock.close(); } catch { /* already closed */ }
52
+ resolve(ok);
53
+ };
54
+ const timer = window.setTimeout(() => finish(false), PROBE_TIMEOUT_MS);
55
+ try {
56
+ sock = new WebSocket(`ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(ticket)}&probe=1`);
57
+ } catch {
58
+ finish(false);
59
+ return;
60
+ }
61
+ sock.onmessage = (ev) => finish(ev.data === "ok");
62
+ sock.onerror = () => finish(false);
63
+ sock.onclose = () => finish(false);
64
+ });
65
+ }
66
+
67
+ async function attempt() {
68
+ if (probing || localBase) return;
69
+ probing = true;
70
+ try {
71
+ const res = await fetch("/api/local-link", { credentials: "same-origin", cache: "no-store" });
72
+ if (!res.ok) return;
73
+ const body = (await res.json()) as { port?: number; ticket?: string };
74
+ if (!Number.isInteger(body.port) || typeof body.ticket !== "string") return;
75
+ if (!(await probe(body.port as number, body.ticket))) return;
76
+ localBase = `ws://127.0.0.1:${body.port}/ws?token=${encodeURIComponent(body.ticket)}`;
77
+ onFound?.();
78
+ } catch {
79
+ /* no fast path; the tunnel works as before */
80
+ } finally {
81
+ probing = false;
82
+ }
83
+ }
84
+
85
+ // Probe once; `found` runs when terminal sockets should reconnect to use the
86
+ // fast path.
87
+ export function startLocalLink(found: () => void) {
88
+ onFound = found;
89
+ if (!localLinkEligible()) return;
90
+ void attempt();
91
+ }
92
+
93
+ // A terminal socket to the fast path closed before it opened (the daemon
94
+ // restarted and forgot the ticket, or it stopped): use the tunnel, and look
95
+ // again a little later with a fresh ticket.
96
+ export function localLinkFailed() {
97
+ if (!localBase) return;
98
+ localBase = null;
99
+ window.clearTimeout(retryTimer);
100
+ retryTimer = window.setTimeout(() => void attempt(), RETRY_AFTER_FAILURE_MS);
101
+ }
@@ -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
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isLocalLinkUrl, localLinkEligible } from "../src/utils/localLink";
3
+
4
+ const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36";
5
+ const IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148";
6
+ const pub = { protocol: "https:", hostname: "hop.zhoulab.io" };
7
+
8
+ describe("same-machine fast path", () => {
9
+ it("is tried from a desktop browser on the public hostname", () => {
10
+ expect(localLinkEligible(pub, MAC, 0)).toBe(true);
11
+ });
12
+ it("is not tried from a phone or an iPad posing as a Mac", () => {
13
+ expect(localLinkEligible(pub, IPHONE, 5)).toBe(false);
14
+ expect(localLinkEligible(pub, MAC, 5)).toBe(false);
15
+ });
16
+ it("is not tried when the page is already local", () => {
17
+ expect(localLinkEligible({ protocol: "http:", hostname: "localhost" }, MAC, 0)).toBe(false);
18
+ expect(localLinkEligible({ protocol: "https:", hostname: "127.0.0.1" }, MAC, 0)).toBe(false);
19
+ });
20
+ it("recognises a fast-path socket url", () => {
21
+ expect(isLocalLinkUrl("ws://127.0.0.1:13575/ws?token=ll_x&room=a")).toBe(true);
22
+ expect(isLocalLinkUrl("wss://hop.zhoulab.io/ws?room=a")).toBe(false);
23
+ });
24
+ });
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) {
@@ -10476,6 +10514,33 @@ function isDirectLoopbackRequest(req) {
10476
10514
  return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
10477
10515
  }
10478
10516
 
10517
+ // Same-machine fast path. A browser on this Mac that opens hop through the
10518
+ // public hostname pays Cloudflare's round trip on every keystroke and scroll
10519
+ // tick (70–240 ms, spikes past 1 s, measured 2026-10-13) while the daemon is
10520
+ // a millisecond away. The page asks for a ticket over the tunnel and, if it
10521
+ // can open a WebSocket to 127.0.0.1 with it, attaches there instead. A ticket
10522
+ // stands in for the login that asked for it and only on a DIRECT loopback
10523
+ // connection, so a copy is worthless off this machine; revoking the login
10524
+ // ends its tickets too. Kept in memory: a daemon restart drops them, and the
10525
+ // page falls back to the tunnel and asks again.
10526
+ const LOCAL_LINK_TTL_MS = 12 * 3600 * 1000;
10527
+ const localLinkTickets = new Map(); // ticket -> { token, expiresAt }
10528
+ function issueLocalLinkTicket(sessionToken) {
10529
+ const now = Date.now();
10530
+ for (const [t, rec] of localLinkTickets) if (rec.expiresAt <= now) localLinkTickets.delete(t);
10531
+ while (localLinkTickets.size >= 500) localLinkTickets.delete(localLinkTickets.keys().next().value);
10532
+ const ticket = 'll_' + crypto.randomBytes(24).toString('base64url');
10533
+ localLinkTickets.set(ticket, { token: sessionToken, expiresAt: now + LOCAL_LINK_TTL_MS });
10534
+ return ticket;
10535
+ }
10536
+ function verifyLocalLinkTicket(ticket) {
10537
+ const rec = localLinkTickets.get(ticket);
10538
+ if (!rec) return null;
10539
+ const session = rec.expiresAt > Date.now() ? authSessions.verify(rec.token) : null;
10540
+ if (!session) localLinkTickets.delete(ticket);
10541
+ return session;
10542
+ }
10543
+
10479
10544
  // Every way a request can prove itself, in one place.
10480
10545
  // cookie — a per-device login session (browser, hop-ios)
10481
10546
  // token — Authorization: Bearer <device session token>
@@ -10502,6 +10567,10 @@ function authenticateRequest(req, cookies, { queryToken = null } = {}) {
10502
10567
  }
10503
10568
  if (typeof queryToken === 'string' && queryToken) {
10504
10569
  if (queryToken === SESSION_SECRET && isDirectLoopbackRequest(req)) return { ok: true, via: 'ipc', session: null };
10570
+ if (queryToken.startsWith('ll_') && isDirectLoopbackRequest(req)) {
10571
+ const session = verifyLocalLinkTicket(queryToken);
10572
+ if (session) return { ok: true, via: 'cookie', session };
10573
+ }
10505
10574
  if (isAuthSessionToken(queryToken)) {
10506
10575
  const session = authSessions.verify(queryToken);
10507
10576
  if (session) return { ok: true, via: 'token', session };
@@ -10846,6 +10915,21 @@ function startAuthProxy(authEmitter) {
10846
10915
  return;
10847
10916
  }
10848
10917
 
10918
+ // API: a ticket for the same-machine fast path (see issueLocalLinkTicket).
10919
+ // Browsers only — it stands in for the login cookie.
10920
+ if (parsedUrl.pathname === '/api/local-link' && req.method === 'GET') {
10921
+ setNoCache(res);
10922
+ const token = cookies[SESSION_COOKIE_NAME];
10923
+ if (!isAuthenticated || auth.via !== 'cookie' || !isAuthSessionToken(token)) {
10924
+ res.writeHead(401, { 'Content-Type': 'application/json' });
10925
+ res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
10926
+ return;
10927
+ }
10928
+ res.writeHead(200, { 'Content-Type': 'application/json' });
10929
+ res.end(JSON.stringify({ ok: true, port: PROXY_PORT, ticket: issueLocalLinkTicket(token) }));
10930
+ return;
10931
+ }
10932
+
10849
10933
  // API: per-device login sessions — list, revoke, mint device tokens —
10850
10934
  // and logout (revokes the caller's own session).
10851
10935
  if (parsedUrl.pathname.startsWith('/api/auth/sessions') || parsedUrl.pathname === '/api/logout') {
@@ -12348,7 +12432,7 @@ happens until someone clicks it.
12348
12432
  req.on('data', chunk => body += chunk);
12349
12433
  req.on('end', async () => {
12350
12434
  try {
12351
- const { name, internalName, target } = JSON.parse(body);
12435
+ const { name, internalName, target, condense } = JSON.parse(body);
12352
12436
  const source = resolveSessionInternalName(name, internalName);
12353
12437
  if (!source || !(hopSessionExists(source) || getEffectiveSessionConfig(source))) {
12354
12438
  res.writeHead(404, { 'Content-Type': 'application/json' });
@@ -12389,8 +12473,11 @@ happens until someone clicks it.
12389
12473
  // resume the other's transcript, so the conversation is
12390
12474
  // extracted to a document the new agent reads before its
12391
12475
  // first turn (scripts/trajectory-extract.js). Same tool,
12392
- // or no target, is the conversation fork as before.
12393
- const handoff = !!wantTarget && wantTarget !== sourceKind;
12476
+ // or no target, is the conversation fork as before —
12477
+ // unless `condense` asks for the handoff anyway: a fresh
12478
+ // session of the SAME agent that starts from the condensed
12479
+ // trajectory instead of the full, context-heavy history.
12480
+ const handoff = !!wantTarget && (wantTarget !== sourceKind || condense === true);
12394
12481
  if (handoff && sourceKind === 'shell') {
12395
12482
  res.writeHead(400, { 'Content-Type': 'application/json' });
12396
12483
  res.end(JSON.stringify({ error: 'This session has no claude or codex conversation to hand off' }));
@@ -12401,7 +12488,7 @@ happens until someone clicks it.
12401
12488
  : null;
12402
12489
 
12403
12490
  const sourceDisplay = getSessionDisplayName(source) || source;
12404
- const suffix = handoff ? '-' + wantTarget : '-fork';
12491
+ const suffix = !handoff ? '-fork' : wantTarget === sourceKind ? '-cont' : '-' + wantTarget;
12405
12492
  let forkName = sourceDisplay + suffix;
12406
12493
  let n = 2;
12407
12494
  while (isDisplayNameInUse(forkName) || hopSessionExists(toInternalSessionName(forkName))) {
@@ -12436,7 +12523,10 @@ happens until someone clicks it.
12436
12523
  writePrivateFile(extractPath, result.text);
12437
12524
  const bytes = Buffer.byteLength(result.text);
12438
12525
  const prompt = trajectoryExtract.handoffPrompt(extractPath, bytes, sourceKind);
12439
- command = `${wantTarget} ${shq(prompt)}`;
12526
+ // A claude source keeps its own launch (config dir,
12527
+ // model, flags) when the receiver is claude too.
12528
+ const launcher = wantTarget === 'claude' && rec ? (rebuildClaudeLaunch(rec.launchCmd) || 'claude') : wantTarget;
12529
+ command = `${launcher} ${shq(prompt)}`;
12440
12530
  kind = 'handoff';
12441
12531
  extract = { path: extractPath, bytes, turns: result.turns, totalTurns: result.totalTurns };
12442
12532
  } else if (rec) {
@@ -14487,6 +14577,17 @@ happens until someone clicks it.
14487
14577
  return;
14488
14578
  }
14489
14579
 
14580
+ // The fast-path probe: a page on the public hostname checking that
14581
+ // this machine's daemon answers on loopback and takes its ticket.
14582
+ if (parsedUrl.pathname === '/ws' && parsedUrl.query.probe === '1') {
14583
+ const probe = new WebSocket.Server({ noServer: true });
14584
+ probe.handleUpgrade(req, socket, head, (ws) => {
14585
+ try { ws.send('ok'); ws.close(1000); } catch (e) { }
14586
+ probe.close();
14587
+ });
14588
+ return;
14589
+ }
14590
+
14490
14591
  // Support both /ws?room=X (hay CLI format) and /s/{session}/ws (legacy)
14491
14592
  let sessionName = null;
14492
14593
  let sessionInfo = null;
@@ -17559,7 +17660,13 @@ async function main() {
17559
17660
  try {
17560
17661
  res = await callLocalApi(state, '/api/sessions/restore', 'POST', { dryRun, includeAgents, only: only.length ? only : undefined });
17561
17662
  } catch (e) {
17562
- log(`Restore request failed: ${e.message}`, RED);
17663
+ // "socket hang up" here means the daemon closed the connection
17664
+ // without answering: it was restarted, or died (e.g. its host
17665
+ // never came up), while the restore was in flight. Nothing about
17666
+ // the restore itself failed, so say what to do next.
17667
+ const gone = !isServerRunning();
17668
+ log(`Restore request failed: ${e.message}${gone ? ' — the daemon went away mid-request' : ''}.`, RED);
17669
+ 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
17670
  process.exit(1);
17564
17671
  }
17565
17672
  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.78",
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
  }
@@ -409,7 +439,23 @@ async function main() {
409
439
  res.writeHead(404, { 'Content-Type': 'application/json' });
410
440
  res.end(JSON.stringify({ error: 'Not found' }));
411
441
  });
442
+ // Compressed when the client offers it (every browser does). A Codex
443
+ // scroll redraws ~6.7 KB per wheel tick and compresses 4.3x with a full
444
+ // window (2.3x with a small one: consecutive frames repeat the screen) —
445
+ // the difference between a trackpad flick queueing ~400 KB/s behind the
446
+ // tunnel and ~90 KB/s. Level 1: the CPU cost is a fraction of a ms.
447
+ // HAY_WS_DEFLATE=0 turns it off. Browsers only: the hop CLI and other
448
+ // local clients attach over loopback, where compressing buys nothing.
412
449
  const wss = new WebSocketServer({ noServer: true });
450
+ const wssDeflate = process.env.HAY_WS_DEFLATE === '0' ? wss : new WebSocketServer({
451
+ noServer: true,
452
+ perMessageDeflate: {
453
+ zlibDeflateOptions: { level: 1 },
454
+ threshold: 256,
455
+ concurrencyLimit: 16
456
+ }
457
+ });
458
+ if (wssDeflate !== wss) wssDeflate.on('connection', (ws, req) => wss.emit('connection', ws, req));
413
459
 
414
460
  server.on('upgrade', (req, socket, head) => {
415
461
  const wsUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
@@ -417,8 +463,9 @@ async function main() {
417
463
  socket.destroy();
418
464
  return;
419
465
  }
420
- wss.handleUpgrade(req, socket, head, (ws) => {
421
- wss.emit('connection', ws, req);
466
+ const target = /Mozilla\//.test(String(req.headers['user-agent'] || '')) ? wssDeflate : wss;
467
+ target.handleUpgrade(req, socket, head, (ws) => {
468
+ target.emit('connection', ws, req);
422
469
  });
423
470
  });
424
471
 
@@ -524,6 +571,7 @@ async function main() {
524
571
  } catch (e) { }
525
572
  try {
526
573
  wss.close();
574
+ if (wssDeflate !== wss) wssDeflate.close();
527
575
  } catch (e) { }
528
576
  server.close(() => process.exit(0));
529
577
  setTimeout(() => process.exit(0), 500).unref();
@@ -870,6 +870,67 @@ test('fork falls past a poisoned record cwd to the durable one', async () => {
870
870
  });
871
871
 
872
872
 
873
+ // "Continue in fresh Claude": the same agent, started over from the
874
+ // condensed trajectory instead of resuming the full history. The new session
875
+ // runs the SOURCE's claude launch (a fake one here, so no real agent starts)
876
+ // with the prompt to read the extract first.
877
+ test('condense into the same agent hands off the trajectory to a fresh session', async () => {
878
+ const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/terminals', {
879
+ name: 'condensesrc',
880
+ cwd: tempDir
881
+ }, agentHeaders);
882
+ assert.equal(create.status, 200);
883
+ const internal = create.data.sessionName;
884
+
885
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
886
+ const configDir = path.join(tempDir, 'claude-cfg');
887
+ const projectDir = path.join(configDir, 'projects', tempDir.replace(/[^A-Za-z0-9]/g, '-'));
888
+ await fs.mkdir(projectDir, { recursive: true });
889
+ await fs.writeFile(path.join(projectDir, `${sessionId}.jsonl`), [
890
+ { type: 'user', timestamp: '2026-10-13T10:00:00Z', message: { role: 'user', content: 'tune the scroll pacing' } },
891
+ { type: 'assistant', timestamp: '2026-10-13T10:00:05Z', message: { content: [{ type: 'text', text: 'Batched output to one message per redraw.' }] } }
892
+ ].map((e) => JSON.stringify(e)).join('\n') + '\n');
893
+ const argvFile = path.join(tempDir, 'fake-claude-argv.txt');
894
+ const fakeClaude = path.join(tempDir, 'claude-fake');
895
+ await fs.writeFile(fakeClaude, `#!/bin/sh\nprintf '%s\\n' "$@" > '${argvFile}'\nsleep 30\n`, { mode: 0o755 });
896
+ await fs.mkdir(path.join(hopHome, 'claude-sessions'), { recursive: true });
897
+ await fs.writeFile(path.join(hopHome, 'claude-sessions', `${internal}.json`), JSON.stringify({
898
+ sessionId, cwd: tempDir, configDir, launchCmd: `${fakeClaude} --model opus`,
899
+ updatedAt: new Date().toISOString()
900
+ }), { mode: 0o600 });
901
+
902
+ const fresh = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/fork', {
903
+ internalName: internal, target: 'claude', condense: true
904
+ });
905
+ assert.equal(fresh.status, 200, JSON.stringify(fresh.data));
906
+ assert.equal(fresh.data.kind, 'handoff');
907
+ assert.match(fresh.data.name, /^condensesrc-cont\d*$/);
908
+ assert.equal(fresh.data.extract.turns, 1);
909
+ const extract = await fs.readFile(fresh.data.extract.path, 'utf8');
910
+ assert.match(extract, /tune the scroll pacing/);
911
+
912
+ let argv = '';
913
+ for (let i = 0; i < 40 && !argv; i++) {
914
+ await delay(150);
915
+ argv = await fs.readFile(argvFile, 'utf8').catch(() => '');
916
+ }
917
+ assert.match(argv, /^--model\nopus\n/, `the source's own claude launch, got ${JSON.stringify(argv)}`);
918
+ assert.ok(argv.includes(fresh.data.extract.path), 'the prompt names the extract');
919
+
920
+ const plain = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/fork', {
921
+ internalName: internal, target: 'claude'
922
+ });
923
+ assert.equal(plain.status, 200);
924
+ assert.equal(plain.data.kind, 'claude', 'without condense, the same agent is a conversation fork');
925
+
926
+
927
+ for (const name of [internal, plain.data.internalName, fresh.data.internalName]) {
928
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
929
+ { name, internalName: name });
930
+ }
931
+ });
932
+
933
+
873
934
  // The briefing generator's coverage signal: the server witnesses who was
874
935
  // looking at what, and when. Only a HUMAN-viewing attach may stamp it —
875
936
  // wall tiles are spectators and terminal-api clients are agents.