hop2 0.11.77 → 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,13 @@ 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
+
145
152
  // DEC private modes worth restoring on adopt: cursor keys, mouse (any
146
153
  // flavour and encoding), focus events, alternate screen, bracketed paste,
147
154
  // cursor visibility.
@@ -385,7 +392,7 @@ export class Room extends EventEmitter {
385
392
  this.updateTerminalState(data);
386
393
  this.startTuiGrid();
387
394
  this.answerTerminalQueriesIfHeadless(data);
388
- this.broadcast({ type: "output", data });
395
+ this.queueOutput(data);
389
396
  this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
390
397
  });
391
398
 
@@ -440,6 +447,9 @@ export class Room extends EventEmitter {
440
447
  }
441
448
 
442
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();
443
453
  if (this.cleanupTimer) {
444
454
  clearTimeout(this.cleanupTimer);
445
455
  this.cleanupTimer = null;
@@ -581,6 +591,9 @@ export class Room extends EventEmitter {
581
591
  // Catch-up: output that arrived while the snapshot was being
582
592
  // serialized. Appended INSIDE the snapshot payload so snapshot and
583
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();
584
597
  const catchUp = client.snapshotCatchUp.join("");
585
598
  socket.send(JSON.stringify({
586
599
  type: "snapshot",
@@ -1075,6 +1088,7 @@ export class Room extends EventEmitter {
1075
1088
  }
1076
1089
 
1077
1090
  private sendActiveSize(client: ClientState, ownerId: string) {
1091
+ this.flushOutput();
1078
1092
  client.socket.send(
1079
1093
  JSON.stringify({
1080
1094
  type: "active_size",
@@ -1086,6 +1100,7 @@ export class Room extends EventEmitter {
1086
1100
  }
1087
1101
 
1088
1102
  private broadcastActiveSize(client: ClientState) {
1103
+ this.flushOutput();
1089
1104
  // Broadcast to OTHER clients (not the one typing)
1090
1105
  const payload = JSON.stringify({
1091
1106
  type: "active_size",
@@ -1100,7 +1115,42 @@ export class Room extends EventEmitter {
1100
1115
  }
1101
1116
  }
1102
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
+
1103
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) {
1104
1154
  const payload = JSON.stringify(message);
1105
1155
  const isOutput = (message as { type?: string }).type === "output";
1106
1156
  for (const client of this.clients.values()) {
@@ -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
+ });
@@ -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);
@@ -50,6 +50,7 @@ import {
50
50
  import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
51
51
  import type { ViewsSummary } from "./utils/switcherModel";
52
52
  import { isAppCtrlEnter, isFullscreenChord } from "./utils/fullscreenChord";
53
+ import { isLocalLinkUrl, localLinkFailed, localWsBase, startLocalLink } from "./utils/localLink";
53
54
 
54
55
  const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
55
56
 
@@ -88,6 +89,9 @@ const getLocationRoom = () => {
88
89
  const buildSessionPath = (sessionName: string) => `/s/${encodeURIComponent(sessionName)}/`;
89
90
 
90
91
  const resolveWsUrl = () => {
92
+ // On the machine hop runs on, terminal sockets skip the tunnel.
93
+ const local = localWsBase();
94
+ if (local) return local;
91
95
  // Check for hop session config (when embedded in hop)
92
96
  const hopSession = (window as unknown as { __HOP_SESSION__?: { wsUrl?: string } }).__HOP_SESSION__;
93
97
  if (hopSession?.wsUrl) {
@@ -1761,7 +1765,7 @@ const App = () => {
1761
1765
  // dark) then match the screen instead of a guess.
1762
1766
  const surface = resolveTerminalTheme(themeMode);
1763
1767
  const hex = (c?: string) => (c || "").replace("#", "").slice(0, 6);
1764
- const url = `${wsUrl}?room=${encodeURIComponent(nextSession.room)}&name=${encodeURIComponent(
1768
+ const url = `${wsUrl}${wsUrl.includes("?") ? "&" : "?"}room=${encodeURIComponent(nextSession.room)}&name=${encodeURIComponent(
1765
1769
  nextSession.name
1766
1770
  )}&cols=${cols}&rows=${rows}&replay=${replay}&bg=${hex(surface.background)}&fg=${hex(surface.foreground)}&device=${encodeURIComponent(resolveDeviceKey())}`;
1767
1771
 
@@ -1771,6 +1775,9 @@ const App = () => {
1771
1775
 
1772
1776
  const ws = new WebSocket(url);
1773
1777
  wsRef.current = ws;
1778
+ const viaLocalLink = isLocalLinkUrl(url);
1779
+ let opened = false;
1780
+ ws.addEventListener("open", () => { opened = true; });
1774
1781
  // When this socket began CONNECTING — the watchdog uses it to catch a
1775
1782
  // handshake that hangs (accepted TCP, WS upgrade never completes through
1776
1783
  // the tunnel): it fires no open/close/error, so nothing else can notice.
@@ -2043,6 +2050,9 @@ const App = () => {
2043
2050
  });
2044
2051
 
2045
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();
2046
2056
  if (connectionNonce !== connectNonceRef.current) {
2047
2057
  return;
2048
2058
  }
@@ -2070,6 +2080,11 @@ const App = () => {
2070
2080
  });
2071
2081
  };
2072
2082
 
2083
+ // Once the same-machine fast path answers, reconnect onto it.
2084
+ useEffect(() => {
2085
+ startLocalLink(() => setReconnectToken((value) => value + 1));
2086
+ }, []);
2087
+
2073
2088
  useEffect(() => {
2074
2089
  if (!session || !terminalReady) {
2075
2090
  return;
@@ -2590,12 +2590,12 @@ export const SessionSwitcher = ({
2590
2590
 
2591
2591
  const forkSession = async (s: SwitcherSession, target?: "claude" | "codex") => {
2592
2592
  setSheet(null);
2593
- onNotice(target ? `Handing off to ${target === "codex" ? "Codex" : "Claude"}…` : "Forking…");
2593
+ onNotice(target ? `Condensing for ${target === "codex" ? "Codex" : "Claude"}…` : "Forking…");
2594
2594
  try {
2595
2595
  const res = await fetch("/api/sessions/fork", {
2596
2596
  method: "POST",
2597
2597
  headers: { "Content-Type": "application/json" },
2598
- body: JSON.stringify({ internalName: sessionKey(s), ...(target ? { target } : {}) })
2598
+ body: JSON.stringify({ internalName: sessionKey(s), ...(target ? { target, condense: true } : {}) })
2599
2599
  });
2600
2600
  const data = await res.json().catch(() => ({} as { error?: string; name?: string; internalName?: string; kind?: string; extract?: { turns?: number; bytes?: number } }));
2601
2601
  if (!res.ok) {
@@ -3761,21 +3761,19 @@ export const SessionSwitcher = ({
3761
3761
  <button type="button" onClick={() => { void forkSession(sheetSession); }}>
3762
3762
  Fork session
3763
3763
  </button>
3764
- {/* Crossing tools: the conversation is extracted to a document
3765
- the new agent reads first (context handoff, not a resume).
3766
- Offered for every terminal — the daemon knows what a
3767
- restored session runs better than the process name here —
3768
- minus the tool this one is visibly running already. */}
3769
- {sheetSession.type !== "port" && appLabel(sheetSession) !== "codex" && (
3770
- <button type="button" onClick={() => { void forkSession(sheetSession, "codex"); }}>
3771
- Continue in Codex…
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"}…
3772
3775
  </button>
3773
- )}
3774
- {sheetSession.type !== "port" && appLabel(sheetSession) !== "claude" && (
3775
- <button type="button" onClick={() => { void forkSession(sheetSession, "claude"); }}>
3776
- Continue in Claude…
3777
- </button>
3778
- )}
3776
+ ))}
3779
3777
  <button
3780
3778
  type="button"
3781
3779
  onClick={() => {
@@ -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,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
@@ -10514,6 +10514,33 @@ function isDirectLoopbackRequest(req) {
10514
10514
  return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
10515
10515
  }
10516
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
+
10517
10544
  // Every way a request can prove itself, in one place.
10518
10545
  // cookie — a per-device login session (browser, hop-ios)
10519
10546
  // token — Authorization: Bearer <device session token>
@@ -10540,6 +10567,10 @@ function authenticateRequest(req, cookies, { queryToken = null } = {}) {
10540
10567
  }
10541
10568
  if (typeof queryToken === 'string' && queryToken) {
10542
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
+ }
10543
10574
  if (isAuthSessionToken(queryToken)) {
10544
10575
  const session = authSessions.verify(queryToken);
10545
10576
  if (session) return { ok: true, via: 'token', session };
@@ -10884,6 +10915,21 @@ function startAuthProxy(authEmitter) {
10884
10915
  return;
10885
10916
  }
10886
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
+
10887
10933
  // API: per-device login sessions — list, revoke, mint device tokens —
10888
10934
  // and logout (revokes the caller's own session).
10889
10935
  if (parsedUrl.pathname.startsWith('/api/auth/sessions') || parsedUrl.pathname === '/api/logout') {
@@ -12386,7 +12432,7 @@ happens until someone clicks it.
12386
12432
  req.on('data', chunk => body += chunk);
12387
12433
  req.on('end', async () => {
12388
12434
  try {
12389
- const { name, internalName, target } = JSON.parse(body);
12435
+ const { name, internalName, target, condense } = JSON.parse(body);
12390
12436
  const source = resolveSessionInternalName(name, internalName);
12391
12437
  if (!source || !(hopSessionExists(source) || getEffectiveSessionConfig(source))) {
12392
12438
  res.writeHead(404, { 'Content-Type': 'application/json' });
@@ -12427,8 +12473,11 @@ happens until someone clicks it.
12427
12473
  // resume the other's transcript, so the conversation is
12428
12474
  // extracted to a document the new agent reads before its
12429
12475
  // first turn (scripts/trajectory-extract.js). Same tool,
12430
- // or no target, is the conversation fork as before.
12431
- 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);
12432
12481
  if (handoff && sourceKind === 'shell') {
12433
12482
  res.writeHead(400, { 'Content-Type': 'application/json' });
12434
12483
  res.end(JSON.stringify({ error: 'This session has no claude or codex conversation to hand off' }));
@@ -12439,7 +12488,7 @@ happens until someone clicks it.
12439
12488
  : null;
12440
12489
 
12441
12490
  const sourceDisplay = getSessionDisplayName(source) || source;
12442
- const suffix = handoff ? '-' + wantTarget : '-fork';
12491
+ const suffix = !handoff ? '-fork' : wantTarget === sourceKind ? '-cont' : '-' + wantTarget;
12443
12492
  let forkName = sourceDisplay + suffix;
12444
12493
  let n = 2;
12445
12494
  while (isDisplayNameInUse(forkName) || hopSessionExists(toInternalSessionName(forkName))) {
@@ -12474,7 +12523,10 @@ happens until someone clicks it.
12474
12523
  writePrivateFile(extractPath, result.text);
12475
12524
  const bytes = Buffer.byteLength(result.text);
12476
12525
  const prompt = trajectoryExtract.handoffPrompt(extractPath, bytes, sourceKind);
12477
- 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)}`;
12478
12530
  kind = 'handoff';
12479
12531
  extract = { path: extractPath, bytes, turns: result.turns, totalTurns: result.totalTurns };
12480
12532
  } else if (rec) {
@@ -14525,6 +14577,17 @@ happens until someone clicks it.
14525
14577
  return;
14526
14578
  }
14527
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
+
14528
14591
  // Support both /ws?room=X (hay CLI format) and /s/{session}/ws (legacy)
14529
14592
  let sessionName = null;
14530
14593
  let sessionInfo = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.77",
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": {
@@ -439,7 +439,23 @@ async function main() {
439
439
  res.writeHead(404, { 'Content-Type': 'application/json' });
440
440
  res.end(JSON.stringify({ error: 'Not found' }));
441
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.
442
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));
443
459
 
444
460
  server.on('upgrade', (req, socket, head) => {
445
461
  const wsUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
@@ -447,8 +463,9 @@ async function main() {
447
463
  socket.destroy();
448
464
  return;
449
465
  }
450
- wss.handleUpgrade(req, socket, head, (ws) => {
451
- 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);
452
469
  });
453
470
  });
454
471
 
@@ -554,6 +571,7 @@ async function main() {
554
571
  } catch (e) { }
555
572
  try {
556
573
  wss.close();
574
+ if (wssDeflate !== wss) wssDeflate.close();
557
575
  } catch (e) { }
558
576
  server.close(() => process.exit(0));
559
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.