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.
- package/hay/apps/server/src/rooms.ts +90 -2
- package/hay/apps/server/test/outputBatch.test.ts +67 -0
- package/hay/apps/server/test/roomModes.test.ts +30 -0
- package/hay/apps/server/test/rooms.test.ts +3 -1
- package/hay/apps/web/src/App.tsx +26 -2
- package/hay/apps/web/src/components/SessionSwitcher.tsx +21 -17
- package/hay/apps/web/src/utils/fullscreenChord.ts +18 -0
- package/hay/apps/web/src/utils/localLink.ts +101 -0
- package/hay/apps/web/test/fullscreenChord.test.ts +22 -0
- package/hay/apps/web/test/localLink.test.ts +24 -0
- package/hop +116 -9
- package/package.json +1 -1
- package/scripts/hay-host.js +52 -4
- package/tests/terminal-api.integration.test.cjs +61 -0
|
@@ -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.
|
|
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
|
-
|
|
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);
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ? `
|
|
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
|
-
{/*
|
|
3759
|
-
the new agent reads first (context handoff, not
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 ? '-'
|
|
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
|
-
|
|
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
|
-
|
|
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
package/scripts/hay-host.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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.
|