hop2 0.11.60 → 0.11.62

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.
@@ -21,7 +21,7 @@ import { enableMathHover } from "./utils/mathLinkProvider";
21
21
  import { getMathTip } from "./utils/mathTooltip";
22
22
  import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
23
23
  import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
24
- import { claimOnAttach, claimOnClick } from "./utils/sizeClaim";
24
+ import { claimOnAttach, claimOnClick, isPlainClick } from "./utils/sizeClaim";
25
25
  import { MobileKeyboard } from "./components/MobileKeyboard";
26
26
  import { SessionSwitcher } from "./components/SessionSwitcher";
27
27
  import { SecondaryPane } from "./components/SecondaryPane";
@@ -1356,6 +1356,9 @@ const App = () => {
1356
1356
  // switch, closing the wall into the session, a click, the Fit button) —
1357
1357
  // a reconnect is the socket healing itself, not a person.
1358
1358
  const deliberateAttachRef = useRef(true);
1359
+ // Where the last press on the terminal landed, so a click can be told
1360
+ // apart from the release of a drag.
1361
+ const clickDownRef = useRef<{ x: number; y: number } | null>(null);
1359
1362
  // A canvas that was HIDDEN can come back showing a stale frame: the wall
1360
1363
  // covers the terminal with visibility:hidden, browsers throttle or drop
1361
1364
  // WebGL contexts for hidden canvases, and xterm only repaints rows it
@@ -2170,6 +2173,8 @@ const App = () => {
2170
2173
 
2171
2174
  termRef.current = terminal;
2172
2175
  fitRef.current = fitAddon;
2176
+ // Debug handle for probes and the console (selection, buffer, size).
2177
+ (window as any).__hopTerm = terminal;
2173
2178
  fitWhenReady();
2174
2179
 
2175
2180
  // Register OSC handlers for sequences xterm.js doesn't fully support
@@ -4603,15 +4608,20 @@ const App = () => {
4603
4608
  <section inert={switcherOpen} style={switcherOpen ? { visibility: "hidden" } : undefined} className={`terminal${visibleStatus === "disconnected" || visibleStatus === "ended" ? " degraded" : ""}`}>
4604
4609
  <div
4605
4610
  className="terminal-frame"
4606
- onClick={() => {
4611
+ onMouseDown={(e) => { clickDownRef.current = { x: e.clientX, y: e.clientY }; }}
4612
+ onClick={(e) => {
4607
4613
  // On mobile, don't focus terminal to prevent system keyboard
4608
4614
  if (!isMobile) {
4609
4615
  termRef.current?.focus();
4610
4616
  }
4611
4617
  // A click here is a human act on this surface, so it may
4612
4618
  // take a size some other window left behind. No-op when
4613
- // the session already fits this viewport.
4614
- claimSizeHere();
4619
+ // the session already fits this viewport — and never for
4620
+ // the click that ends a drag: that was a text selection,
4621
+ // and the resize a claim brings would wipe it.
4622
+ if (isPlainClick(clickDownRef.current, { x: e.clientX, y: e.clientY }, !!termRef.current?.hasSelection())) {
4623
+ claimSizeHere();
4624
+ }
4615
4625
  }}
4616
4626
  onDragEnter={(e) => {
4617
4627
  if (Array.from(e.dataTransfer?.types || []).includes("Files")) {
@@ -39,3 +39,20 @@ export const claimOnClick = (o: {
39
39
  if (!o.active) return true;
40
40
  return o.active.cols !== o.natural.cols || o.active.rows !== o.natural.rows;
41
41
  };
42
+
43
+ /**
44
+ * A click is a click only when the pointer stayed put between press and
45
+ * release and nothing is selected. A drag that ends over the terminal also
46
+ * fires `click` — and that drag was a text selection, which a size claim
47
+ * (a resize) would wipe the moment it finished.
48
+ */
49
+ export const isPlainClick = (
50
+ down: { x: number; y: number } | null,
51
+ up: { x: number; y: number },
52
+ hasSelection: boolean,
53
+ slopPx = 4
54
+ ): boolean => {
55
+ if (hasSelection) return false;
56
+ if (!down) return true;
57
+ return Math.hypot(up.x - down.x, up.y - down.y) <= slopPx;
58
+ };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { claimOnAttach, claimOnClick } from "../src/utils/sizeClaim";
2
+ import { claimOnAttach, claimOnClick, isPlainClick } from "../src/utils/sizeClaim";
3
3
 
4
4
  describe("claimOnAttach — only a deliberate open takes the size", () => {
5
5
  const base = { viewMode: "fit" as const, visible: true, deliberate: true };
@@ -29,3 +29,14 @@ describe("claimOnClick — a click takes the size only when it changes something
29
29
  expect(claimOnClick({ viewMode: "fit", natural: null, active: null, owned: false })).toBe(false);
30
30
  });
31
31
  });
32
+
33
+ describe("isPlainClick — the release of a drag is not a click", () => {
34
+ it("a press and release in the same place, nothing selected, is a click", () => {
35
+ expect(isPlainClick({ x: 100, y: 100 }, { x: 102, y: 101 }, false)).toBe(true);
36
+ expect(isPlainClick(null, { x: 5, y: 5 }, false)).toBe(true);
37
+ });
38
+ it("a drag, or any release with a selection standing, is not", () => {
39
+ expect(isPlainClick({ x: 100, y: 100 }, { x: 260, y: 100 }, false)).toBe(false);
40
+ expect(isPlainClick({ x: 100, y: 100 }, { x: 100, y: 100 }, true)).toBe(false);
41
+ });
42
+ });
package/hop CHANGED
@@ -5530,6 +5530,48 @@ function findCodexRolloutById(sessionId) {
5530
5530
  return null;
5531
5531
  }
5532
5532
 
5533
+ /**
5534
+ * The first line of a rollout (its session_meta), read WITHOUT loading the
5535
+ * file — a long-lived codex thread runs to hundreds of MB.
5536
+ */
5537
+ function readCodexRolloutMeta(file) {
5538
+ let fd = null;
5539
+ try {
5540
+ fd = fs.openSync(file, 'r');
5541
+ const buf = Buffer.alloc(64 * 1024);
5542
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
5543
+ const text = buf.toString('utf8', 0, n);
5544
+ const line = text.slice(0, text.indexOf('\n') >= 0 ? text.indexOf('\n') : text.length);
5545
+ const parsed = JSON.parse(line);
5546
+ return parsed && parsed.type === 'session_meta' ? (parsed.payload || null) : null;
5547
+ } catch (e) {
5548
+ return null;
5549
+ } finally {
5550
+ if (fd !== null) { try { fs.closeSync(fd); } catch (e) { /* ignore */ } }
5551
+ }
5552
+ }
5553
+
5554
+ /**
5555
+ * The thread `codex resume` must be given for a conversation: the ROOT. The
5556
+ * notify hook records whichever thread finished a turn, and with multi-agent
5557
+ * codex that is often a sub-agent (its rollout's session_meta carries a
5558
+ * parent_thread_id); resuming a sub-agent is refused ("resume the parent
5559
+ * first"), which stranded a restored session in a bare shell. Walk up to the
5560
+ * root, reading only each rollout's first line. Unknown ids come back as given.
5561
+ */
5562
+ function codexRootThreadId(sessionId) {
5563
+ let id = sessionId;
5564
+ for (let hops = 0; hops < 8 && id; hops++) {
5565
+ const file = findCodexRolloutById(id);
5566
+ if (!file) return id;
5567
+ const meta = readCodexRolloutMeta(file);
5568
+ const parent = meta && typeof meta.parent_thread_id === 'string' ? meta.parent_thread_id : null;
5569
+ if (!parent || parent === id) return id;
5570
+ id = parent;
5571
+ }
5572
+ return id;
5573
+ }
5574
+
5533
5575
  // Find a transcript for a bare session id across every claude config root,
5534
5576
  // returning which root (= the CLAUDE_CONFIG_DIR to resume under) holds it.
5535
5577
  function findClaudeTranscriptById(sessionId) {
@@ -5947,6 +5989,26 @@ function rebuildClaudeLaunch(launchCmd) {
5947
5989
  // that project directory.
5948
5990
  function restorePlanForSession(entry) {
5949
5991
  let rec = readClaudeSessionRecord(entry.internalName);
5992
+ // Codex has no SessionStart record; its notify hook leaves only the
5993
+ // .turn naming the thread. Without this, every codex session came back
5994
+ // as a bare shell with its conversation stranded.
5995
+ if (!rec) {
5996
+ const turn = readTurnInfo(entry.internalName);
5997
+ if (turn && turn.agent === 'codex') {
5998
+ const metaCwd = readClaudeSessionMeta(entry.internalName)?.cwd;
5999
+ const cwd = metaCwd || entry.cwd;
6000
+ // The hook may have named a sub-agent's thread; resume the root.
6001
+ const rootId = codexRootThreadId(turn.sessionId);
6002
+ if (!findCodexRolloutById(rootId)) {
6003
+ return {
6004
+ command: null,
6005
+ cwd,
6006
+ warning: `recorded codex conversation ${String(turn.sessionId).slice(0, 8)}… has no rollout on this host — opening a plain shell instead`
6007
+ };
6008
+ }
6009
+ return { command: `codex resume '${rootId.replace(/'/g, `'\\''`)}'`, cwd, agent: 'codex' };
6010
+ }
6011
+ }
5950
6012
  // No SessionStart record, but the turn counter may still name this room's
5951
6013
  // conversation (the two are written independently). Recover a record from
5952
6014
  // it when the transcript is real — otherwise this room would drop to a
@@ -6147,6 +6209,7 @@ function recordRestoreExpectations(planned) {
6147
6209
  const state = readRestoreReconcile();
6148
6210
  for (const p of planned || []) {
6149
6211
  if (!p || !p.internalName || !p.command) continue; // no claude command → nothing to converge
6212
+ if (p.agent && p.agent !== 'claude') continue; // the reconciler only knows claude
6150
6213
  state.sessions[p.internalName] = {
6151
6214
  internalName: p.internalName,
6152
6215
  displayName: p.displayName || p.internalName,
@@ -6500,6 +6563,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6500
6563
  displayName: e.displayName || e.internalName,
6501
6564
  cwd: plan.cwd || e.cwd || process.cwd(),
6502
6565
  command: plan.command,
6566
+ agent: plan.agent || (plan.command ? 'claude' : null),
6503
6567
  warning: plan.warning || null,
6504
6568
  // Plain-shell sessions (no claude/startup command) replay their last
6505
6569
  // screen if we persisted a buffer for them at the previous shutdown.
@@ -6544,7 +6608,11 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6544
6608
  } catch (e) {
6545
6609
  continue;
6546
6610
  }
6547
- if (p.command && entry) {
6611
+ if (p.command && entry && p.agent !== 'claude') {
6612
+ // The verifier below reads claude's chrome (and may re-type the
6613
+ // command); a codex resume is launched as argv and left alone.
6614
+ setTimeout(() => { try { closeTerminalApiEntry(entry.id); } catch (e) { /* ignore */ } }, 600);
6615
+ } else if (p.command && entry) {
6548
6616
  // Launched with the command as argv: nothing to type — just
6549
6617
  // confirm it actually came up.
6550
6618
  pending.push({ plan: p, entry, state: 'verify', attempts: 1, since: Date.now() });
@@ -11681,7 +11749,7 @@ happens until someone clicks it.
11681
11749
  // names the agent and the conversation — the process name
11682
11750
  // says "node" and cannot), or a plain shell.
11683
11751
  const turn = readTurnInfo(source);
11684
- const codexId = turn?.agent === 'codex' ? turn.sessionId : null;
11752
+ const codexId = turn?.agent === 'codex' ? codexRootThreadId(turn.sessionId) : null;
11685
11753
  const runsCodex = !!codexId
11686
11754
  || /codex/i.test(String(config.foregroundProcess || ''))
11687
11755
  || /codex/i.test(String(getRuntimeSessionMetadata(source)?.foregroundProcess || ''));
@@ -16201,23 +16269,22 @@ async function main() {
16201
16269
  }
16202
16270
 
16203
16271
  if (stopAll) {
16204
- // Order matters: session deletion needs the daemon, so close sessions
16205
- // first, then stop the daemon, then kill the hay-host process.
16272
+ // Stop the daemon, then SIGTERM the hay-host. Sessions are NOT
16273
+ // deleted: /api/sessions/delete is the explicit "forget this
16274
+ // session" path (drops its config, display name, and restore
16275
+ // records), so calling it here made every `stop --all` wipe the
16276
+ // restore inventory — 2026-09-26 a full restart lost all 34 live
16277
+ // sessions and `hop restore` found nothing of them. The host's
16278
+ // graceful shutdown persists buffers and keeps every record.
16206
16279
  if (state) {
16207
16280
  try {
16208
16281
  const sessionsRes = await fetchSessionList(state);
16209
16282
  const sessions = Array.isArray(sessionsRes?.sessions) ? sessionsRes.sessions : [];
16210
- for (const s of sessions) {
16211
- try {
16212
- await callLocalApi(state, '/api/sessions/delete', 'POST', { name: s.name || s.internalName });
16213
- } catch (e) { }
16283
+ const liveCount = sessions.filter((s) => s && s.live !== false).length;
16284
+ if (liveCount > 0) {
16285
+ log(`Ending ${liveCount} session(s) — restore records kept (${CYAN}hop restore${RESET}${GREEN} brings them back).`, GREEN);
16214
16286
  }
16215
- if (sessions.length > 0) {
16216
- log(`Closed ${sessions.length} session(s).`, GREEN);
16217
- }
16218
- } catch (e) {
16219
- log(`Warning: could not close sessions via daemon: ${e.message}`, YELLOW);
16220
- }
16287
+ } catch (e) { /* count is informational only */ }
16221
16288
  try {
16222
16289
  process.kill(state.pid, 'SIGTERM');
16223
16290
  log(`Hop daemon stopped.`, GREEN);
@@ -16227,7 +16294,7 @@ async function main() {
16227
16294
  }
16228
16295
  }
16229
16296
  if (hayHostAlive) {
16230
- const stopped = await stopExternalHayHostProcess(hayHost);
16297
+ const stopped = await stopExternalHayHostProcess(hayHost, 8000);
16231
16298
  if (!stopped) {
16232
16299
  log(`Failed to stop hay-host (pid ${hayHost.pid}).`, RED);
16233
16300
  process.exit(1);
@@ -16483,7 +16550,7 @@ async function main() {
16483
16550
  // by the newest rollout in this directory.
16484
16551
  const turn = readTurnInfo(target);
16485
16552
  const rollout = turn?.agent === 'codex' ? null : resolveCodexTranscriptByCwd(cwd);
16486
- const rolloutId = turn?.agent === 'codex' ? turn.sessionId : (rollout ? await trajectoryExtract.codexRolloutId(rollout) : null);
16553
+ const rolloutId = turn?.agent === 'codex' ? codexRootThreadId(turn.sessionId) : (rollout ? await trajectoryExtract.codexRolloutId(rollout) : null);
16487
16554
  if (rolloutId) {
16488
16555
  command = `codex resume '${rolloutId.replace(/'/g, `'\\''`)}'`;
16489
16556
  kind = 'codex';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.60",
3
+ "version": "0.11.62",
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": {
@@ -31,6 +31,45 @@ function turnFilePath() {
31
31
  return path.join(dir, `${process.env.HOP_SESSION}.turn`);
32
32
  }
33
33
 
34
+ // A sub-agent's rollout names its parent in its first line (session_meta →
35
+ // parent_thread_id). Walk up to the root, reading only that line of each
36
+ // file — a long thread's rollout runs to hundreds of MB. Any failure leaves
37
+ // the id as given; hop resolves it again at restore time.
38
+ function findRollout(id) {
39
+ const root = path.join(os.homedir(), ".codex", "sessions");
40
+ const walk = (dir, depth) => {
41
+ let names = [];
42
+ try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; }
43
+ for (const e of names) {
44
+ const p = path.join(dir, e.name);
45
+ if (e.isDirectory() && depth < 3) { const hit = walk(p, depth + 1); if (hit) return hit; }
46
+ else if (e.isFile() && e.name.endsWith(".jsonl") && e.name.includes(id)) return p;
47
+ }
48
+ return null;
49
+ };
50
+ return walk(root, 0);
51
+ }
52
+ function rootThreadId(id) {
53
+ let cur = id;
54
+ for (let hops = 0; hops < 8 && cur; hops++) {
55
+ const file = findRollout(cur);
56
+ if (!file) return cur;
57
+ let parent = null;
58
+ try {
59
+ const fd = fs.openSync(file, "r");
60
+ const buf = Buffer.alloc(65536);
61
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
62
+ fs.closeSync(fd);
63
+ const text = buf.toString("utf8", 0, n);
64
+ const meta = JSON.parse(text.slice(0, text.indexOf("\n") >= 0 ? text.indexOf("\n") : text.length));
65
+ parent = meta && meta.payload && typeof meta.payload.parent_thread_id === "string" ? meta.payload.parent_thread_id : null;
66
+ } catch { return cur; }
67
+ if (!parent || parent === cur) return cur;
68
+ cur = parent;
69
+ }
70
+ return cur;
71
+ }
72
+
34
73
  // Same crash-safe write + same {sessionId, count, at} shape the Claude hook
35
74
  // uses, so hop's finishBump() reads one number regardless of which agent wrote.
36
75
  function bumpTurn(payload) {
@@ -42,12 +81,15 @@ function bumpTurn(payload) {
42
81
  const prev = JSON.parse(fs.readFileSync(file, "utf8"));
43
82
  if (prev && Number.isInteger(prev.count) && prev.count >= 0) count = prev.count;
44
83
  } catch { /* missing/corrupt -> start at 0 */ }
84
+ const threadId = (payload && typeof payload["thread-id"] === "string") ? payload["thread-id"]
85
+ : (payload && typeof payload.thread_id === "string") ? payload.thread_id
86
+ : (payload && typeof payload.session_id === "string") ? payload.session_id : null;
45
87
  const rec = JSON.stringify({
46
88
  // The thread id is the conversation (and the rollout file's name); the
47
- // turn id names one turn and matches nothing on disk.
48
- sessionId: (payload && typeof payload["thread-id"] === "string") ? payload["thread-id"]
49
- : (payload && typeof payload.thread_id === "string") ? payload.thread_id
50
- : (payload && typeof payload.session_id === "string") ? payload.session_id : null,
89
+ // turn id names one turn and matches nothing on disk. With multi-agent
90
+ // codex the thread that finished may be a SUB-agent, which cannot be
91
+ // resumed on its own — record the root, which can.
92
+ sessionId: rootThreadId(threadId),
51
93
  count: count + 1,
52
94
  at: new Date().toISOString(),
53
95
  agent: "codex"