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.
- package/hay/apps/server/src/rooms.ts +51 -1
- package/hay/apps/server/test/outputBatch.test.ts +67 -0
- package/hay/apps/server/test/rooms.test.ts +3 -1
- package/hay/apps/web/src/App.tsx +16 -1
- package/hay/apps/web/src/components/SessionSwitcher.tsx +14 -16
- package/hay/apps/web/src/utils/localLink.ts +101 -0
- package/hay/apps/web/test/localLink.test.ts +24 -0
- package/hop +68 -5
- package/package.json +1 -1
- package/scripts/hay-host.js +20 -2
- package/tests/terminal-api.integration.test.cjs +61 -0
|
@@ -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.
|
|
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);
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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 ? `
|
|
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
|
-
{/*
|
|
3765
|
-
the new agent reads first (context handoff, not
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
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
|
-
|
|
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 ? '-'
|
|
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
|
-
|
|
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
package/scripts/hay-host.js
CHANGED
|
@@ -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
|
-
|
|
451
|
-
|
|
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.
|