hop2 0.11.75 → 0.11.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -142,6 +142,11 @@ export type RoomSummary = {
142
142
  // 6MB still exceeds every reader: the deep-history replay asks 1.5MB, the
143
143
  // serialized attach reads the grid, and `hop restore` persists 64KB tails.
144
144
  // Override with HAY_SNAPSHOT_BUFFER_BYTES for deeper rings at GC's expense.
145
+ // DEC private modes worth restoring on adopt: cursor keys, mouse (any
146
+ // flavour and encoding), focus events, alternate screen, bracketed paste,
147
+ // cursor visibility.
148
+ const TRACKED_DEC_MODES = new Set(["1", "25", "47", "1000", "1002", "1003", "1004", "1005", "1006", "1015", "1047", "1049", "2004"]);
149
+
145
150
  const MAX_BUFFER_SIZE = (() => {
146
151
  const env = Number(process.env.HAY_SNAPSHOT_BUFFER_BYTES);
147
152
  return Number.isFinite(env) && env > 0 ? env : 6_000_000;
@@ -321,6 +326,13 @@ export class Room extends EventEmitter {
321
326
  private mouseReporting = false;
322
327
  private mouseSgr = false;
323
328
  private cursorHidden = false;
329
+ // The DEC private modes an app set (alternate screen, mouse, focus,
330
+ // bracketed paste…), exactly. An app sets them once at startup; a host
331
+ // restart rebuilds the room from a bounded replay that no longer holds
332
+ // that moment, so the room forgot Codex was full-screen and the wheel
333
+ // scrolled the page instead of the app (2026-10-12). The host saves
334
+ // modePrefix() and puts it back in front of the replay on adopt.
335
+ private decModes = new Map<string, boolean>();
324
336
  private controlSequenceTail = "";
325
337
  private oscBuffer = "";
326
338
  private cleanupTimer: NodeJS.Timeout | null = null;
@@ -678,10 +690,13 @@ export class Room extends EventEmitter {
678
690
 
679
691
  let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
680
692
  let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
693
+ const modesBefore = this.modePrefix();
694
+ if (hasNewRis) this.decModes.clear();
681
695
  while ((match = regex.exec(stateInput)) !== null) {
682
696
  const params = match[1].split(";").filter(Boolean);
683
697
  const mode = match[2];
684
698
  for (const param of params) {
699
+ if (TRACKED_DEC_MODES.has(param)) this.decModes.set(param, mode === "h");
685
700
  if (param === "25") {
686
701
  nextCursorHidden = mode === "l";
687
702
  } else if (param === "47" || param === "1047" || param === "1049") {
@@ -738,6 +753,9 @@ export class Room extends EventEmitter {
738
753
  stateChanged = true;
739
754
  }
740
755
 
756
+ const modesAfter = this.modePrefix();
757
+ if (modesAfter !== modesBefore) this.emit("modes", modesAfter);
758
+
741
759
  if (stateChanged) {
742
760
  this.emit("pty_state", {
743
761
  roomId: this.id,
@@ -1204,6 +1222,22 @@ export class Room extends EventEmitter {
1204
1222
  // omit cells until the app happens to repaint (for example, on scroll).
1205
1223
  // Begin tracking at TUI entry; dependency loading is shared and normally
1206
1224
  // finishes first, but the callback covers a TUI that starts immediately.
1225
+ /**
1226
+ * The escape sequences that put a fresh terminal into this app's current
1227
+ * modes: the alternate screen first (so the rest apply to it), then each
1228
+ * mode that is on, and a hidden cursor. "" for a plain shell.
1229
+ */
1230
+ modePrefix(): string {
1231
+ let alt = "", rest = "";
1232
+ for (const [mode, on] of this.decModes) {
1233
+ if (mode === "25") { if (!on) rest += "\x1b[?25l"; continue; }
1234
+ if (!on) continue;
1235
+ if (mode === "1049" || mode === "1047" || mode === "47") alt = `\x1b[?${mode}h`;
1236
+ else rest += `\x1b[?${mode}h`;
1237
+ }
1238
+ return alt + rest;
1239
+ }
1240
+
1207
1241
  private startTuiGrid() {
1208
1242
  if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
1209
1243
  if (!this.alternateScreen && !this.mouseReporting) return;
@@ -1244,7 +1278,11 @@ export class Room extends EventEmitter {
1244
1278
  const nl = tail.indexOf("\n");
1245
1279
  if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
1246
1280
  }
1247
- this.grid.write(tail);
1281
+ // The tail rarely reaches back to where the app set its modes
1282
+ // (an adopted room's saved prefix sits at the very front of the
1283
+ // ring): put the room's current modes first, or the grid serializes
1284
+ // a full-screen app as a plain shell. The tail's own changes win.
1285
+ this.grid.write(this.modePrefix() + tail);
1248
1286
  }
1249
1287
  }
1250
1288
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RoomManager } from "../src/rooms";
3
+ import type { PtyFactory } from "../src/pty";
4
+
5
+ class FakePty {
6
+ private handlers: Array<(data: string) => void> = [];
7
+ onData(handler: (data: string) => void) { this.handlers.push(handler); }
8
+ onExit() {}
9
+ emit(data: string) { for (const h of this.handlers) h(data); }
10
+ write() {}
11
+ resize() {}
12
+ kill() {}
13
+ }
14
+
15
+ describe("terminal modes a room remembers", () => {
16
+ it("a room reports the DEC modes its app set, alternate screen first", () => {
17
+ const pty = new FakePty();
18
+ const manager = new RoomManager((() => pty) as unknown as PtyFactory);
19
+ const room = manager.getRoom("codex", { cols: 80, rows: 24 }, "/tmp");
20
+ const seen: string[] = [];
21
+ room.on("modes", (p: string) => seen.push(p));
22
+ pty.emit("\x1b[?2004h\x1b[?1004h\x1b[?1049h\x1b[?1003h\x1b[?1006h\x1b[?25l hello");
23
+ expect(room.modePrefix()).toBe("\x1b[?1049h\x1b[?2004h\x1b[?1004h\x1b[?1003h\x1b[?1006h\x1b[?25l");
24
+ expect(seen.at(-1)).toBe(room.modePrefix());
25
+ pty.emit("\x1b[?1003l\x1b[?1049l");
26
+ expect(room.modePrefix()).toBe("\x1b[?2004h\x1b[?1004h\x1b[?1006h\x1b[?25l");
27
+ pty.emit("\x1bc");
28
+ expect(room.modePrefix()).toBe("");
29
+ });
30
+ });
@@ -49,6 +49,7 @@ import {
49
49
  } from "./utils/paneTree";
50
50
  import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
51
51
  import type { ViewsSummary } from "./utils/switcherModel";
52
+ import { isAppCtrlEnter, isFullscreenChord } from "./utils/fullscreenChord";
52
53
 
53
54
  const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
54
55
 
@@ -2329,13 +2330,21 @@ const App = () => {
2329
2330
  terminal.attachCustomKeyEventHandler((event) => {
2330
2331
  // ⌘⏎ leaves full screen for the wall — the mirror of ⌘⏎ on a wall
2331
2332
  // tile entering it. One chord toggles the mode from either side.
2332
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
2333
+ // On a Mac only ⌘⏎ — Ctrl⏎ goes to the app (see fullscreenChord.ts).
2334
+ if (isFullscreenChord(event)) {
2333
2335
  if (event.type === 'keydown') {
2334
2336
  event.preventDefault();
2335
2337
  setSwitcherOpen(true);
2336
2338
  }
2337
2339
  return false;
2338
2340
  }
2341
+ if (isAppCtrlEnter(event) && (remoteKbdEnhancedRef.current || foregroundIsClaude())) {
2342
+ if (event.type === 'keydown') {
2343
+ event.preventDefault();
2344
+ handleUserInput('\x1b[13;5u');
2345
+ }
2346
+ return false;
2347
+ }
2339
2348
  // Long-press SPACE = voice input — shared controller; see voiceHold.ts.
2340
2349
  {
2341
2350
  const verdict = voiceHoldRef.current?.handleKey(event);
@@ -37,6 +37,7 @@ import {
37
37
  type SwitcherSortMode,
38
38
  insertAfter, describeAccount, type AgentHome, type AgentHomes } from "../utils/switcherModel";
39
39
  import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
40
+ import { isAppCtrlEnter, isFullscreenChord } from "../utils/fullscreenChord";
40
41
  import { DigestCard } from "./DigestCard";
41
42
  import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
42
43
 
@@ -693,10 +694,15 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
693
694
  if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;2u");
694
695
  return false;
695
696
  }
696
- if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey && ev.key === "Enter") {
697
+ // On a Mac only ⌘⏎ — Ctrl⏎ goes to the app (see fullscreenChord.ts).
698
+ if (isFullscreenChord(ev)) {
697
699
  if (ev.type === "keydown") onFullscreenRef.current();
698
700
  return false;
699
701
  }
702
+ if (isAppCtrlEnter(ev) && (kbdEnhancedRef.current || looksLikeClaude())) {
703
+ if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;5u");
704
+ return false;
705
+ }
700
706
  if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey && ev.key === ".") {
701
707
  if (ev.type === "keydown") onUnfocusRef.current();
702
708
  return false;
@@ -0,0 +1,18 @@
1
+ // The full-screen ⇄ wall chord inside a terminal. On a Mac it is ⌘⏎ only:
2
+ // Ctrl⏎ belongs to the app (Claude Code binds it), and hop taking it meant
3
+ // the keystroke never arrived. Elsewhere there is no ⌘, so Ctrl⏎ stays.
4
+ export const IS_MAC = typeof navigator !== "undefined"
5
+ && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "");
6
+
7
+ type Chord = { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean };
8
+
9
+ export function isFullscreenChord(ev: Chord, mac = IS_MAC): boolean {
10
+ if (ev.key !== "Enter" || ev.altKey || ev.shiftKey) return false;
11
+ return mac ? ev.metaKey && !ev.ctrlKey : ev.metaKey || ev.ctrlKey;
12
+ }
13
+
14
+ /** Ctrl⏎ passed to the app: CSI-u (13;5u), since xterm.js would send a bare \r. */
15
+ export function isAppCtrlEnter(ev: Chord, mac = IS_MAC): boolean {
16
+ return ev.key === "Enter" && ev.ctrlKey && !ev.metaKey && !ev.altKey && !ev.shiftKey
17
+ && !isFullscreenChord(ev, mac);
18
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isAppCtrlEnter, isFullscreenChord } from "../src/utils/fullscreenChord";
3
+
4
+ const key = (mods: Partial<{ metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }>, k = "Enter") =>
5
+ ({ key: k, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...mods });
6
+
7
+ describe("full-screen chord", () => {
8
+ it("on a Mac only ⌘⏎ toggles; Ctrl⏎ is the app's", () => {
9
+ expect(isFullscreenChord(key({ metaKey: true }), true)).toBe(true);
10
+ expect(isFullscreenChord(key({ ctrlKey: true }), true)).toBe(false);
11
+ expect(isAppCtrlEnter(key({ ctrlKey: true }), true)).toBe(true);
12
+ });
13
+ it("without ⌘ (Windows/Linux) Ctrl⏎ still toggles", () => {
14
+ expect(isFullscreenChord(key({ ctrlKey: true }), false)).toBe(true);
15
+ expect(isAppCtrlEnter(key({ ctrlKey: true }), false)).toBe(false);
16
+ });
17
+ it("other chords are untouched", () => {
18
+ expect(isFullscreenChord(key({ metaKey: true, shiftKey: true }), true)).toBe(false);
19
+ expect(isFullscreenChord(key({}), true)).toBe(false);
20
+ expect(isAppCtrlEnter(key({ ctrlKey: true }, "a"), true)).toBe(false);
21
+ });
22
+ });
package/hop CHANGED
@@ -802,7 +802,18 @@ async function withLifecycleLock(op, fn, { waitMs = 120_000, onWait } = {}) {
802
802
  }
803
803
  // Marker so the authenticator setup QR is only shown on first run (UX only; no auth logic).
804
804
  const AUTH_QR_SHOWN_FILE = path.join(HOME_DIR, '.auth_qr_shown');
805
- const HAY_HOST_READY_TIMEOUT_MS = parsePositiveIntEnv('HOP_HAY_HOST_READY_TIMEOUT_MS', 5000);
805
+ // A keeper-backed host re-adopts every running terminal (replaying each
806
+ // screen) BEFORE it opens its port. With 30 rooms that took ~8 s, then ~41 s
807
+ // once one replay timed out (2026-10-12), so first a 5 s and then a 25 s
808
+ // limit killed each new host mid-adoption and spawned the next — a restart
809
+ // loop that left hop with no host, and took the daemon down with it ("Fatal:
810
+ // Hay host did not open its port in time") while a `hop restore` was in
811
+ // flight, which the CLI reported as "socket hang up". This is now a ceiling,
812
+ // not the expected wait: the spawner keeps waiting while the host process is
813
+ // alive (see waitForHayHostPort), gives up early only once it has died, and
814
+ // refreshes the spawn lock as it waits so a rival spawner never mistakes a
815
+ // long adoption for a stale lock.
816
+ const HAY_HOST_READY_TIMEOUT_MS = parsePositiveIntEnv('HOP_HAY_HOST_READY_TIMEOUT_MS', 180000);
806
817
  const HAY_HOST_DISCOVERY_CACHE_MS = parsePositiveIntEnv('HOP_HAY_HOST_DISCOVERY_CACHE_MS', 10000);
807
818
  const HAY_HOST_HOME_HASH = crypto.createHash('sha256').update(path.resolve(HOME_DIR)).digest('hex').slice(0, 24);
808
819
  const HAY_HOST_HOME_FLAG = `--hop-home-hash=${HAY_HOST_HOME_HASH}`;
@@ -2733,6 +2744,20 @@ const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc
2733
2744
  const TRUST_PROMPT_RE = /Quick\s*safety\s*check|Yes,?\s*I\s*trust\s*this\s*folder|Is\s*this\s*a\s*project\s*you\s*created\s*or\s*one\s*you\s*trust/i;
2734
2745
  // Trust-dialog reading lives in lib/trust-dialog.js so it can be unit-tested.
2735
2746
  const { trustDialogWorkspace, trustDialogAnswer, samePath } = require('./lib/trust-dialog.js');
2747
+ // The transport cloudflared uses to reach Cloudflare's edge. Its default is
2748
+ // QUIC (UDP): on networks that shape or drop UDP — campus networks often do —
2749
+ // a lost packet stalls every request in flight on that connection for a
2750
+ // retransmit timeout, and keystrokes from a phone or laptop arrive in
2751
+ // 1–3 s bursts while the host itself is idle (measured 2026-10-11: QUIC
2752
+ // connection 0 had 120 timeout losses; one request in four took 1.2–2.9 s).
2753
+ // "http2" runs the tunnel over TCP instead. Set `tunnel_protocol` in
2754
+ // ~/.hop2 config ("quic" | "http2" | "auto") or HOP_TUNNEL_PROTOCOL.
2755
+ function getTunnelProtocol() {
2756
+ const raw = String(process.env.HOP_TUNNEL_PROTOCOL || getHopConfig().tunnel_protocol || '').toLowerCase();
2757
+ return raw === 'quic' || raw === 'http2' || raw === 'auto' ? raw : null;
2758
+ }
2759
+ const tunnelProtocolLine = () => { const p = getTunnelProtocol(); return p ? `protocol: ${p}\n` : ''; };
2760
+
2736
2761
  function getResumeMode() {
2737
2762
  const mode = String(getHopConfig().resume_mode || '').toLowerCase();
2738
2763
  return mode === 'summary' || mode === 'ask' ? mode : 'full';
@@ -6021,6 +6046,20 @@ function runShellProbe(command, cwd) {
6021
6046
  // agent-turn path has always sent them as separate writes; this is that.
6022
6047
  const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
6023
6048
  // Is the message still sitting in the composer (the `›` line) — typed, not sent?
6049
+ /**
6050
+ * Is this session's agent up and showing its own UI — the only thing a
6051
+ * check-back may type into? Not running, a plain shell, a trust or resume
6052
+ * dialog, or an unreadable screen are all "no".
6053
+ */
6054
+ async function agentScreenReady(entry) {
6055
+ if (!entry || entry.live !== true) return { ok: false, reason: 'waiting: the session is not running' };
6056
+ const verdict = await roomIsRedoable(entry);
6057
+ // roomIsRedoable says "not redoable, and here is whether the agent is
6058
+ // idle" exactly when an agent's UI owns the screen.
6059
+ if (!verdict.ok && verdict.agentIdle !== undefined) return { ok: true };
6060
+ return { ok: false, reason: `waiting: no agent on screen (${verdict.reason})` };
6061
+ }
6062
+
6024
6063
  async function composerStillHolds(internalName, message) {
6025
6064
  try {
6026
6065
  const hostRes = await callExternalHayHostApi(`/rooms/${encodeURIComponent(internalName)}/serialized`);
@@ -6100,6 +6139,17 @@ async function runCheckbackSweep() {
6100
6139
  continue;
6101
6140
  }
6102
6141
  if (!checkbacks.mayDeliver(cb, ctx)) continue; // mid-turn: next sweep
6142
+ // Type only into a RUNNING agent. Attaching to a stopped
6143
+ // session creates its room — as a bare shell — and the
6144
+ // message then ran as shell commands ("zsh: command not
6145
+ // found: Collect"); worse, that shell made restore think the
6146
+ // session was up and skip it (surf, alphagenome, 2026-10-12
6147
+ // after a reboot). Wait until the agent's own UI is on screen.
6148
+ const ready = await agentScreenReady(entry);
6149
+ if (!ready.ok) {
6150
+ if (cb.lastError !== ready.reason) { cb.lastError = ready.reason; dirty = true; }
6151
+ continue;
6152
+ }
6103
6153
  const e2 = await attachTerminalApiClient(cb.session, entry.displayName || cb.session, { actor: 'system', label: 'checkback', typeOnly: true });
6104
6154
  try { await typeAndSubmit(e2, cb.session, cb.message); }
6105
6155
  finally { try { closeTerminalApiEntry(e2.id); } catch (e) { /* closed */ } }
@@ -6384,8 +6434,13 @@ function rebuildClaudeLaunch(launchCmd) {
6384
6434
  if (!valued && out.includes(t)) continue;
6385
6435
  out.push(t);
6386
6436
  if (valued) out.push(tokens[++i]);
6437
+ continue;
6387
6438
  }
6388
- // bare positional (initial prompt) — dropped
6439
+ // A bare positional is the initial PROMPT, and everything after it
6440
+ // is prompt text too — split on spaces, its words included things
6441
+ // like "--source-ref" and "-q", which were kept as flags and made
6442
+ // claude exit at once on every restore (pulse-1004-claude-o3).
6443
+ break;
6389
6444
  }
6390
6445
  return out.join(' ');
6391
6446
  }
@@ -6965,6 +7020,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6965
7020
  const { resolved, unknown } = resolveRestoreTargets(only, snapshot);
6966
7021
  for (const name of unknown) skipped.push({ name, reason: 'no such session' });
6967
7022
  onlySet = resolved;
7023
+ let closedAny = false;
6968
7024
  for (const internalName of resolved) {
6969
7025
  if (!live.has(internalName)) continue;
6970
7026
  const entry = snapshot.entries.find((e) => e.internalName === internalName);
@@ -6999,7 +7055,13 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6999
7055
  }
7000
7056
  log(` ${display}: closed the ${verdict.reason} left by the last restore`, DIM);
7001
7057
  live.delete(internalName);
7058
+ closedAny = true;
7002
7059
  }
7060
+ // The keeper ends the old terminal a beat AFTER the room closes. A
7061
+ // relaunch inside that beat was refused ("already running"), and
7062
+ // then the old one exited too — both sessions vanished (alphagenome,
7063
+ // angler-codex-claude-codex, 2026-10-12). Let the old ones go first.
7064
+ if (closedAny) await new Promise((r) => setTimeout(r, 2000));
7003
7065
  }
7004
7066
  const candidates = snapshot.entries.filter((e) => e && e.type === 'terminal' && !live.has(e.internalName));
7005
7067
  // Primary source: the SessionStart-hook records. Add any recorded session that
@@ -9655,6 +9717,27 @@ async function stopExternalHayHostProcess(hostState, timeoutMs = 1500) {
9655
9717
  return !isProcessAlive(pid);
9656
9718
  }
9657
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
+
9658
9741
  function markExternalHayHostResponsive(host) {
9659
9742
  const recovered = hayHostProbeFailures.recordSuccess(Number(host?.pid));
9660
9743
  if (recovered) {
@@ -9781,6 +9864,9 @@ async function ensureExternalHayHostReadyOnce() {
9781
9864
  if (spawned && isProcessAlive(spawned.pid) && await probeExternalHayHostFromHost(spawned, 1500)) {
9782
9865
  return markExternalHayHostResponsive(spawned);
9783
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;
9784
9870
  }
9785
9871
  // Winner never delivered — fall through and spawn ourselves.
9786
9872
  }
@@ -9866,10 +9952,13 @@ async function ensureExternalHayHostReadyOnce() {
9866
9952
  throw new Error(`Hay host failed to start; see ${hayHostLogFile}`);
9867
9953
  }
9868
9954
 
9869
- const ready = await waitForPort(started.port, '127.0.0.1', HAY_HOST_READY_TIMEOUT_MS, 120);
9955
+ const ready = await waitForHayHostPort(started, HAY_HOST_READY_TIMEOUT_MS);
9870
9956
  if (!ready) {
9957
+ const died = !isProcessAlive(started.pid);
9871
9958
  await stopExternalHayHostProcess(started);
9872
- throw new Error(`Hay host did not open its port in time; see ${hayHostLogFile}`);
9959
+ throw new Error(died
9960
+ ? `Hay host (pid ${started.pid}) exited before opening its port; see ${hayHostLogFile}`
9961
+ : `Hay host did not open its port within ${Math.round(HAY_HOST_READY_TIMEOUT_MS / 1000)}s (still adopting its terminals? HOP_HAY_HOST_READY_TIMEOUT_MS raises the ceiling); see ${hayHostLogFile}`);
9873
9962
  }
9874
9963
  const healthy = await probeExternalHayHostFromHost(started, 1500);
9875
9964
  if (!healthy) {
@@ -13320,7 +13409,11 @@ happens until someone clicks it.
13320
13409
  const result = await restoreSavedSessions({
13321
13410
  dryRun: parsed.dryRun === true,
13322
13411
  includeAgents: parsed.includeAgents === true,
13323
- only: Array.isArray(parsed.only) ? parsed.only.filter((n) => typeof n === 'string').slice(0, 200) : null
13412
+ only: Array.isArray(parsed.only) ? parsed.only.filter((n) => typeof n === 'string').slice(0, 200) : null,
13413
+ // Restart named agents that sit idle at their prompt
13414
+ // (never one mid-turn) — e.g. one resumed the wrong
13415
+ // conversation. Only together with `only`.
13416
+ replaceIdleAgent: parsed.replaceIdleAgent === true && Array.isArray(parsed.only)
13324
13417
  });
13325
13418
  res.writeHead(200, { 'Content-Type': 'application/json' });
13326
13419
  res.end(JSON.stringify(result));
@@ -14497,7 +14590,24 @@ happens until someone clicks it.
14497
14590
  return;
14498
14591
  }
14499
14592
  await reconcileExternalRuntimeSessions();
14500
- await ensureExternalRuntimeSession(sessionName, sessionConfig?.cwd, getSessionDisplayName(sessionName), {
14593
+ // A dead session that holds an agent conversation comes
14594
+ // back AS that agent, whoever opens it — a viewer, a
14595
+ // check-back, a tile. Creating a bare shell here left
14596
+ // surf a zsh prompt after a reboot (a check-back's attach
14597
+ // beat restore by two seconds, and restore then skipped
14598
+ // the "running" session). Same plan restore would use.
14599
+ let revive = null;
14600
+ if (!sessionConfig?.startup && !(await runtimeTerminalSessionExists(sessionName))) {
14601
+ try {
14602
+ const plan = restorePlanForSession({ internalName: sessionName, cwd: sessionConfig?.cwd, startup: sessionConfig?.startup, autoStart: sessionConfig?.autoStart });
14603
+ if (plan?.command) {
14604
+ revive = plan;
14605
+ log(`[ws] ${sessionName}: not running — starting it as its agent (${plan.command.slice(0, 80)})`, DIM);
14606
+ }
14607
+ } catch (e) { /* no plan: a plain shell, as before */ }
14608
+ }
14609
+ await ensureExternalRuntimeSession(sessionName, revive?.cwd || sessionConfig?.cwd, getSessionDisplayName(sessionName), {
14610
+ command: revive?.command || undefined,
14501
14611
  shell: sessionConfig?.shell,
14502
14612
  env: sessionConfig?.env,
14503
14613
  cols: sessionConfig?.cols,
@@ -14729,7 +14839,7 @@ function startTunnel(isRestart = false) {
14729
14839
  const tunnelConfig = `
14730
14840
  url: http://${localTunnelHost}:${PROXY_PORT}
14731
14841
  tunnel: ${domainConfig.tunnelName}
14732
- `;
14842
+ ${tunnelProtocolLine()}`;
14733
14843
  writePrivateFile(tunnelConfigPath, tunnelConfig);
14734
14844
 
14735
14845
  const runArgs = ['tunnel', '--config', tunnelConfigPath, 'run'];
@@ -14907,7 +15017,7 @@ function startClientTunnel(isRestart = false) {
14907
15017
  url: http://127.0.0.1:${PROXY_PORT}
14908
15018
  tunnel: ${tunnelId}
14909
15019
  credentials-file: ${path.resolve(credentialsPath)}
14910
- `;
15020
+ ${tunnelProtocolLine()}`;
14911
15021
  writePrivateFile(tunnelConfigPath, tunnelConfig);
14912
15022
 
14913
15023
  const tunnel = spawn('cloudflared', ['tunnel', '--config', tunnelConfigPath, 'run'], {
@@ -17487,7 +17597,13 @@ async function main() {
17487
17597
  try {
17488
17598
  res = await callLocalApi(state, '/api/sessions/restore', 'POST', { dryRun, includeAgents, only: only.length ? only : undefined });
17489
17599
  } catch (e) {
17490
- log(`Restore request failed: ${e.message}`, RED);
17600
+ // "socket hang up" here means the daemon closed the connection
17601
+ // without answering: it was restarted, or died (e.g. its host
17602
+ // never came up), while the restore was in flight. Nothing about
17603
+ // the restore itself failed, so say what to do next.
17604
+ const gone = !isServerRunning();
17605
+ log(`Restore request failed: ${e.message}${gone ? ' — the daemon went away mid-request' : ''}.`, RED);
17606
+ log(` ${DIM}Check ${CYAN}hop logs${RESET}${DIM} (look for "Fatal:" or "Cleaning up"), then run ${CYAN}hop restore${RESET}${DIM} again — it only recreates sessions that are not running.${RESET}`, RESET);
17491
17607
  process.exit(1);
17492
17608
  }
17493
17609
  if (!res || res.status < 200 || res.status >= 300) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.75",
3
+ "version": "0.11.77",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -38,7 +38,17 @@ function turnsDir() {
38
38
  // thread is the truth: if another session's turn record already names this
39
39
  // root thread, the turn is that session's. (hop launches codex without the
40
40
  // daemon now; this keeps the sessions that predate the change honest.)
41
+ //
42
+ // Retired (2026-10-12): every codex hop launches now carries its own session
43
+ // in the per-thread overrides (lib/codex-identity.js), so HOP_SESSION is the
44
+ // truth — and the "another record already names this thread" rule did harm:
45
+ // once a record held the wrong thread, every later turn of that thread was
46
+ // filed there for good. The hand-off session angler-codex-claude-codex lost
47
+ // its whole conversation to angler-codex that way, and after a reboot
48
+ // angler-codex resumed the hand-off's thread instead of its own. Kept only
49
+ // for a launch with no session in its environment.
41
50
  function resolveHopSession(dir, rootId, envSession) {
51
+ if (envSession) return envSession;
42
52
  if (!rootId) return envSession;
43
53
  let names = [];
44
54
  try { names = fs.readdirSync(dir).filter((f) => f.endsWith(".turn")); } catch { return envSession; }
@@ -58,7 +68,9 @@ function resolveHopSession(dir, rootId, envSession) {
58
68
  // file — a long thread's rollout runs to hundreds of MB. Any failure leaves
59
69
  // the id as given; hop resolves it again at restore time.
60
70
  function findRollout(id) {
61
- const root = path.join(os.homedir(), ".codex", "sessions");
71
+ // The account's own root: a session running as another Codex login keeps
72
+ // its rollouts under that CODEX_HOME.
73
+ const root = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions");
62
74
  const walk = (dir, depth) => {
63
75
  let names = [];
64
76
  try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; }
@@ -101,14 +113,24 @@ function bumpTurn(payload) {
101
113
  const rawThread = (payload && typeof payload["thread-id"] === "string") ? payload["thread-id"]
102
114
  : (payload && typeof payload.thread_id === "string") ? payload.thread_id
103
115
  : (payload && typeof payload.session_id === "string") ? payload.session_id : null;
104
- const rootId = rootThreadId(rawThread);
116
+ let rootId = rootThreadId(rawThread);
105
117
  const hopSession = resolveHopSession(dir, rootId, envSession);
106
118
  const file = path.join(dir, `${hopSession}.turn`);
107
119
  let count = 0;
120
+ let prev = null;
108
121
  try {
109
- const prev = JSON.parse(fs.readFileSync(file, "utf8"));
122
+ prev = JSON.parse(fs.readFileSync(file, "utf8"));
110
123
  if (prev && Number.isInteger(prev.count) && prev.count >= 0) count = prev.count;
111
124
  } catch { /* missing/corrupt -> start at 0 */ }
125
+ // A thread Codex never saved cannot be the conversation. Codex's helper
126
+ // threads (the approval "guardian", among others) finish turns and notify
127
+ // like any other, but have no rollout — recording one replaced the real
128
+ // thread, and restore then had nothing to resume (alphagenome came back a
129
+ // bare shell). Keep the saved thread already on record.
130
+ if (rootId && !findRollout(rootId) && prev && typeof prev.sessionId === "string"
131
+ && prev.sessionId !== rootId && findRollout(prev.sessionId)) {
132
+ rootId = prev.sessionId;
133
+ }
112
134
  const rec = JSON.stringify({
113
135
  // The thread id is the conversation (and the rollout file's name); the
114
136
  // turn id names one turn and matches nothing on disk. With multi-agent
@@ -91,10 +91,19 @@ function loadViewerColors(hay) {
91
91
  if (typeof hay.setDefaultViewerColors === 'function') hay.setDefaultViewerColors(saved.bg, saved.fg);
92
92
  } catch (e) { /* none yet: the dark default */ }
93
93
  }
94
+ // Compared against what is on disk, not setDefaultViewerColors' "changed":
95
+ // room.setClientColors already updates the defaults before this runs, so
96
+ // that always said "unchanged" and the file was never written — every host
97
+ // restart went back to the dark default (Codex's charcoal composer).
98
+ let savedViewerColors = null;
94
99
  function rememberViewerColors(hay, bg, fg) {
95
100
  if (typeof hay.setDefaultViewerColors !== 'function') return;
96
- if (!hay.setDefaultViewerColors(bg, fg)) return;
97
- try { fs.writeFileSync(VIEWER_COLORS_FILE, JSON.stringify(hay.getDefaultViewerColors())); } catch (e) { /* best effort */ }
101
+ hay.setDefaultViewerColors(bg, fg);
102
+ const json = JSON.stringify(hay.getDefaultViewerColors());
103
+ if (savedViewerColors === null) { try { savedViewerColors = fs.readFileSync(VIEWER_COLORS_FILE, 'utf8'); } catch (e) { savedViewerColors = ''; } }
104
+ if (json === savedViewerColors) return;
105
+ try { fs.writeFileSync(VIEWER_COLORS_FILE, json); savedViewerColors = json; } catch (e) { /* best effort */ }
106
+ }
98
107
  const CLAUDE_SESSIONS_DIR = path.join(HOP_HOME, 'claude-sessions');
99
108
 
100
109
  // Scrub Claude Code SESSION markers from our environment before any PTY is
@@ -153,9 +162,29 @@ const watchedRooms = new WeakSet();
153
162
  // restore <name>`): their records must survive the close, exactly as they
154
163
  // survive a host shutdown. Consumed once, on the next session_end.
155
164
  const preserveRecordsOnClose = new Set();
165
+ // Each room's terminal modes (Room.modePrefix), saved as they change so a
166
+ // re-adopted room starts in them: an app turns alternate screen and mouse
167
+ // reporting on once, at startup, which a bounded replay no longer holds.
168
+ const ROOM_MODES_DIR = path.join(HOP_HOME, 'room-modes');
169
+ const roomModesFile = (id) => path.join(ROOM_MODES_DIR, `${id}.modes`);
170
+ function loadRoomModes(id) {
171
+ if (!/^[A-Za-z0-9_.-]+$/.test(String(id || ''))) return '';
172
+ try { return fs.readFileSync(roomModesFile(id), 'utf8'); } catch (e) { return ''; }
173
+ }
174
+ function saveRoomModes(id, prefix) {
175
+ try {
176
+ if (!prefix) { fs.unlinkSync(roomModesFile(id)); return; }
177
+ fs.mkdirSync(ROOM_MODES_DIR, { recursive: true });
178
+ fs.writeFileSync(roomModesFile(id), prefix);
179
+ } catch (e) { /* best effort */ }
180
+ }
156
181
  function watchRoomEnd(room) {
157
182
  if (!room || typeof room.on !== 'function' || watchedRooms.has(room)) return room;
158
183
  watchedRooms.add(room);
184
+ if (/^[A-Za-z0-9_.-]+$/.test(String(room.id || ''))) {
185
+ room.on('modes', (prefix) => saveRoomModes(String(room.id), String(prefix || '')));
186
+ if (typeof room.modePrefix === 'function') saveRoomModes(String(room.id), room.modePrefix());
187
+ }
159
188
  room.on('session_end', () => {
160
189
  if (shuttingDown) return;
161
190
  const id = String(room.id || '');
@@ -168,6 +197,7 @@ function watchRoomEnd(room) {
168
197
  // request to forget its conversation; that is an explicit delete's
169
198
  // job, daemon-side.
170
199
  try { fs.unlinkSync(path.join(BUFFER_DIR, `${id}.raw`)); } catch (e) { /* best effort */ }
200
+ try { fs.unlinkSync(roomModesFile(id)); } catch (e) { /* best effort */ }
171
201
  });
172
202
  return room;
173
203
  }
@@ -10,10 +10,15 @@ const HOOK = path.join(__dirname, '..', 'scripts', 'codex-notify-hook.js');
10
10
  const freshHome = () => {
11
11
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'hop-codex-hook-'));
12
12
  fs.mkdirSync(path.join(home, 'claude-sessions'), { recursive: true });
13
+ fs.mkdirSync(path.join(home, 'codex', 'sessions', '2026', '10', '12'), { recursive: true });
13
14
  return home;
14
15
  };
16
+ // A thread Codex saved has a rollout file named after it.
17
+ const saveRollout = (home, id) => fs.writeFileSync(
18
+ path.join(home, 'codex', 'sessions', '2026', '10', '12', `rollout-2026-10-12T00-00-00-${id}.jsonl`),
19
+ JSON.stringify({ type: 'session_meta', payload: { id } }) + '\n');
15
20
  const run = (home, session, payload) => execFileSync(process.execPath, [HOOK, JSON.stringify(payload)], {
16
- env: { ...process.env, HOP_HOME: home, HOP_SESSION: session }
21
+ env: { ...process.env, HOP_HOME: home, HOP_SESSION: session, CODEX_HOME: path.join(home, 'codex') }
17
22
  });
18
23
  const turn = (home, name) => { try { return JSON.parse(fs.readFileSync(path.join(home, 'claude-sessions', `${name}.turn`), 'utf8')); } catch { return null; } };
19
24
 
@@ -25,19 +30,30 @@ test('a turn is counted against HOP_SESSION when no other session claims the thr
25
30
  assert.equal(turn(home, 'alpha').agent, 'codex');
26
31
  });
27
32
 
28
- test("under a shared daemon the environment lies: the session whose record already names the thread gets the turn", () => {
33
+ test('the turn belongs to the session it ran in, even when another record names the thread', () => {
34
+ // Retired rule: "a record already naming this thread owns it" filed the
35
+ // hand-off session's turns under angler-codex for good. Every hop launch
36
+ // now carries its own session, so the environment is the truth.
29
37
  const home = freshHome();
30
- fs.writeFileSync(path.join(home, 'claude-sessions', 'surf.turn'), JSON.stringify({ sessionId: 'thread-S', count: 4, at: '2026-09-28T22:46:53.450Z', agent: 'codex' }));
31
- fs.writeFileSync(path.join(home, 'claude-sessions', 'owner.turn'), JSON.stringify({ sessionId: 'thread-O', count: 9, at: '2026-09-29T20:00:00.000Z', agent: 'codex' }));
32
- // The daemon runs notify with HOP_SESSION=owner, for surf's thread.
33
- run(home, 'owner', { type: 'agent-turn-complete', 'thread-id': 'thread-S', 'turn-id': 't2' });
34
- assert.equal(turn(home, 'surf').count, 5, 'surf got its turn');
35
- assert.equal(turn(home, 'surf').sessionId, 'thread-S');
36
- assert.equal(turn(home, 'owner').count, 9, 'the owner was not credited with it');
37
- assert.equal(turn(home, 'owner').sessionId, 'thread-O', "and the owner's own thread id was not overwritten");
38
- // The owner's own turn still lands on the owner.
39
- run(home, 'owner', { type: 'agent-turn-complete', 'thread-id': 'thread-O', 'turn-id': 't3' });
40
- assert.equal(turn(home, 'owner').count, 10);
38
+ saveRollout(home, 'thread-H'); saveRollout(home, 'thread-A');
39
+ fs.writeFileSync(path.join(home, 'claude-sessions', 'angler.turn'), JSON.stringify({ sessionId: 'thread-H', count: 9, at: '2026-10-09T00:00:00.000Z', agent: 'codex' }));
40
+ run(home, 'handoff', { type: 'agent-turn-complete', 'thread-id': 'thread-H', 'turn-id': 't1' });
41
+ assert.equal(turn(home, 'handoff').sessionId, 'thread-H');
42
+ assert.equal(turn(home, 'handoff').count, 1);
43
+ assert.equal(turn(home, 'angler').count, 9, 'the other record is left alone');
44
+ });
45
+
46
+ test("a thread Codex never saved (a helper like the approval guardian) does not replace the session's conversation", () => {
47
+ const home = freshHome();
48
+ saveRollout(home, 'thread-main');
49
+ run(home, 'alpha', { type: 'agent-turn-complete', 'thread-id': 'thread-main', 'turn-id': 't1' });
50
+ run(home, 'alpha', { type: 'agent-turn-complete', 'thread-id': 'thread-guardian', 'turn-id': 't2' });
51
+ assert.equal(turn(home, 'alpha').sessionId, 'thread-main', 'still the saved thread');
52
+ assert.equal(turn(home, 'alpha').count, 2, 'the turn still counts');
53
+ // A NEW saved thread (the person started over) does take over.
54
+ saveRollout(home, 'thread-next');
55
+ run(home, 'alpha', { type: 'agent-turn-complete', 'thread-id': 'thread-next', 'turn-id': 't3' });
56
+ assert.equal(turn(home, 'alpha').sessionId, 'thread-next');
41
57
  });
42
58
 
43
59
  test('other event types count nothing', () => {