hop2 0.11.82 → 0.11.83

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.
@@ -88,10 +88,12 @@ const getLocationRoom = () => {
88
88
 
89
89
  const buildSessionPath = (sessionName: string) => `/s/${encodeURIComponent(sessionName)}/`;
90
90
 
91
- const resolveWsUrl = () => {
92
- // On the machine hop runs on, terminal sockets skip the tunnel.
93
- const local = localWsBase();
94
- if (local) return local;
91
+ // On the machine hop runs on, terminal sockets skip the tunnel.
92
+ const resolveWsUrl = () => localWsBase() ?? resolveTunnelWsUrl();
93
+
94
+ // The page's own socket url. Wall tiles and panes take this and choose the
95
+ // fast path per connect (currentWsBase), so a dead fast path never strands them.
96
+ const resolveTunnelWsUrl = () => {
95
97
  // Check for hop session config (when embedded in hop)
96
98
  const hopSession = (window as unknown as { __HOP_SESSION__?: { wsUrl?: string } }).__HOP_SESSION__;
97
99
  if (hopSession?.wsUrl) {
@@ -4350,7 +4352,7 @@ const App = () => {
4350
4352
  }}
4351
4353
  onRefresh={() => fetchSessions({ showLoading: false })}
4352
4354
  onNotice={showToast}
4353
- tileWsBase={resolveWsUrl()}
4355
+ tileWsBase={resolveTunnelWsUrl()}
4354
4356
  onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
4355
4357
  onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
4356
4358
  userName={name}
@@ -4975,7 +4977,7 @@ const App = () => {
4975
4977
  <SecondaryPane
4976
4978
  sessionName={leaf.session as string}
4977
4979
  procLabel={paneProc === "zsh" || paneProc === "bash" ? "" : paneProc}
4978
- wsUrl={resolveWsUrl()}
4980
+ wsUrl={resolveTunnelWsUrl()}
4979
4981
  userName={name}
4980
4982
  cols={120}
4981
4983
  rows={30}
@@ -5015,7 +5017,7 @@ const App = () => {
5015
5017
  }}
5016
5018
  onRefresh={() => fetchSessions({ showLoading: false })}
5017
5019
  onNotice={showToast}
5018
- tileWsBase={resolveWsUrl()}
5020
+ tileWsBase={resolveTunnelWsUrl()}
5019
5021
  onFocusSession={focusSessionInPlace}
5020
5022
  folders={folders}
5021
5023
  onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
@@ -8,6 +8,7 @@ import { PaneButtons, PaneIcon, type DragHandle } from "./PaneLayout";
8
8
  import { enableMathHover } from "../utils/mathLinkProvider";
9
9
  import { getMathTip } from "../utils/mathTooltip";
10
10
  import type { Side } from "../utils/paneTree";
11
+ import { currentWsBase, noteSocketClosed } from "../utils/localLink";
11
12
 
12
13
  // A lightweight additional pane: its own WS attach + xterm for one session,
13
14
  // typing forwarded when focused. A FOCUSED pane claims its box size (crisp
@@ -115,14 +116,17 @@ export const SecondaryPane = ({
115
116
  };
116
117
 
117
118
  const connect = () => {
118
- const sep = wsUrl.includes("?") ? "&" : "?";
119
- const ws = new WebSocket(
120
- `${wsUrl}${sep}room=${encodeURIComponent(sessionName)}&name=${encodeURIComponent(userName + " (pane)")}` +
121
- `&cols=${sizeRef.current.cols}&rows=${sizeRef.current.rows}`
122
- );
119
+ // The fast path while it works, else the tunnel url this pane was given.
120
+ const base = currentWsBase(wsUrl);
121
+ const sep = base.includes("?") ? "&" : "?";
122
+ const url = `${base}${sep}room=${encodeURIComponent(sessionName)}&name=${encodeURIComponent(userName + " (pane)")}` +
123
+ `&cols=${sizeRef.current.cols}&rows=${sizeRef.current.rows}`;
124
+ const ws = new WebSocket(url);
123
125
  wsRef.current = ws;
124
126
  showConnecting();
127
+ let opened = false;
125
128
  ws.onopen = () => {
129
+ opened = true;
126
130
  everConnected = true;
127
131
  attempt = 0; // a healed link earns a fresh immediate retry next time
128
132
  window.clearTimeout(chromeTimer);
@@ -167,6 +171,7 @@ export const SecondaryPane = ({
167
171
  }
168
172
  };
169
173
  ws.onclose = () => {
174
+ noteSocketClosed(url, opened);
170
175
  if (!shouldReconnect) return;
171
176
  showConnecting();
172
177
  // Immediate first retry: most drops are a tunnel blip that has
@@ -39,6 +39,7 @@ import {
39
39
  import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
40
40
  import { isAppCtrlEnter, isFullscreenChord } from "../utils/fullscreenChord";
41
41
  import { chipWhen } from "../utils/checkbackText";
42
+ import { currentWsBase, noteSocketClosed } from "../utils/localLink";
42
43
  import { DigestCard } from "./DigestCard";
43
44
  import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
44
45
 
@@ -839,11 +840,12 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
839
840
  return;
840
841
  }
841
842
  lastClaimRef.current = { cols: dims.cols, rows: dims.rows, at: performance.now() };
842
- const sep = wsBase.includes("?") ? "&" : "?";
843
+ const base = currentWsBase(wsBase);
844
+ const sep = base.includes("?") ? "&" : "?";
843
845
  // Claim-only socket: no replay, closed as soon as the claim is sent. The
844
846
  // watch poll then repaints at the new size.
845
847
  const sock = new WebSocket(
846
- wsBase + sep + "room=" + encodeURIComponent(room)
848
+ base + sep + "room=" + encodeURIComponent(room)
847
849
  + "&name=" + encodeURIComponent((userName || "user") + " (wall)")
848
850
  + "&source=monitor&replay=0&cols=" + dims.cols + "&rows=" + dims.rows
849
851
  );
@@ -970,10 +972,11 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
970
972
  // plenty, and preview → live becomes the SAME renderer painting the same
971
973
  // bytes at the same geometry. Full screen keeps WebGL.
972
974
  setConn("live");
973
- const sep = wsBase.includes("?") ? "&" : "?";
974
- const wsUrl = () =>
975
- wsBase + sep + "room=" + encodeURIComponent(room) + "&name=" + encodeURIComponent(userName || "user")
975
+ const wsUrl = () => {
976
+ const base = currentWsBase(wsBase);
977
+ return base + (base.includes("?") ? "&" : "?") + "room=" + encodeURIComponent(room) + "&name=" + encodeURIComponent(userName || "user")
976
978
  + "&replay=65536&nudge=0&cols=" + term.cols + "&rows=" + term.rows;
979
+ };
977
980
  let ws: WebSocket | null = null;
978
981
  let disposed = false;
979
982
  let reconnectTimer = 0;
@@ -1030,9 +1033,12 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
1030
1033
  if (disposed) return;
1031
1034
  window.clearTimeout(reconnectTimer);
1032
1035
  if (ws) { try { ws.close(); } catch { /* replacing */ } }
1033
- const sock = new WebSocket(wsUrl());
1036
+ const url = wsUrl();
1037
+ const sock = new WebSocket(url);
1034
1038
  ws = sock;
1039
+ let opened = false;
1035
1040
  sock.onopen = () => {
1041
+ opened = true;
1036
1042
  if (disposed || ws !== sock) return;
1037
1043
  reconnectAttempt = 0;
1038
1044
  markLive();
@@ -1091,6 +1097,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
1091
1097
  } catch { /* non-JSON frame */ }
1092
1098
  };
1093
1099
  sock.onclose = () => {
1100
+ noteSocketClosed(url, opened);
1094
1101
  if (disposed || ws !== sock) return;
1095
1102
  scheduleReconnect();
1096
1103
  };
@@ -2397,9 +2404,10 @@ export const SessionSwitcher = ({
2397
2404
  if (session.type !== "port" && !session.active && interactiveTiles && tileWsBase) {
2398
2405
  const key = sessionKey(session);
2399
2406
  try {
2400
- const sep = tileWsBase.includes("?") ? "&" : "?";
2407
+ const base = currentWsBase(tileWsBase);
2408
+ const sep = base.includes("?") ? "&" : "?";
2401
2409
  const kick = new WebSocket(
2402
- tileWsBase + sep + "room=" + encodeURIComponent(key)
2410
+ base + sep + "room=" + encodeURIComponent(key)
2403
2411
  + "&name=" + encodeURIComponent((userName || "user") + " (start)")
2404
2412
  + "&replay=0&nudge=0&cols=80&rows=24"
2405
2413
  );
@@ -11,12 +11,19 @@
11
11
  // services on this device"); the probe waits while that prompt is up. It runs
12
12
  // beside the tunnel connection, so waiting costs nothing.
13
13
  const PROBE_TIMEOUT_MS = 120_000;
14
- const RETRY_AFTER_FAILURE_MS = 30_000;
14
+ // After the fast path drops (almost always a daemon restart, which takes a
15
+ // few seconds), look again soon and back off: 1 s, 2 s, 4 s … 30 s, then
16
+ // stop until the next drop. A flat 30 s left a tab on the slow tunnel and
17
+ // then yanked it back with a second reconnect half a minute later.
18
+ const RETRY_BASE_MS = 1_000;
19
+ const RETRY_MAX_MS = 30_000;
20
+ const RETRY_LIMIT = 10;
15
21
  const OPT_OUT_KEY = "hop.localLink"; // "off" disables the fast path in this browser
16
22
 
17
23
  let localBase: string | null = null;
18
24
  let probing = false;
19
25
  let retryTimer = 0;
26
+ let retries = -1; // -1: the fast path never worked here, so a failure is final
20
27
  let onFound: (() => void) | null = null;
21
28
 
22
29
  export const isLocalLinkUrl = (url: string) => /^ws:\/\/127\.0\.0\.1:\d+\//.test(url);
@@ -25,6 +32,19 @@ export const isLocalLinkUrl = (url: string) => /^ws:\/\/127\.0\.0\.1:\d+\//.test
25
32
  // or null to use the page's host.
26
33
  export const localWsBase = () => localBase;
27
34
 
35
+ // The base a socket should dial NOW: the fast path while it works, else the
36
+ // tunnel base it was given. Wall tiles and panes ask on every (re)connect —
37
+ // holding the fast-path url from their first render, they kept redialing it
38
+ // after it died (a daemon restart, a lapsed ticket) and only full screen,
39
+ // which falls back, could connect.
40
+ export const currentWsBase = (tunnelBase: string) => localBase ?? tunnelBase;
41
+
42
+ // Any terminal socket that closed: one dialed on the fast path that never
43
+ // opened means the fast path is down for every socket on the page.
44
+ export function noteSocketClosed(url: string, opened: boolean) {
45
+ if (!opened && isLocalLinkUrl(url)) localLinkFailed();
46
+ }
47
+
28
48
  export function localLinkEligible(
29
49
  loc: { protocol: string; hostname: string } = window.location,
30
50
  ua: string = navigator.userAgent,
@@ -64,9 +84,18 @@ function probe(port: number, ticket: string): Promise<boolean> {
64
84
  });
65
85
  }
66
86
 
87
+ function scheduleRetry() {
88
+ if (retries < 0 || retries >= RETRY_LIMIT) return;
89
+ const delay = Math.min(RETRY_BASE_MS * 2 ** retries, RETRY_MAX_MS);
90
+ retries += 1;
91
+ window.clearTimeout(retryTimer);
92
+ retryTimer = window.setTimeout(() => void attempt(), delay);
93
+ }
94
+
67
95
  async function attempt() {
68
96
  if (probing || localBase) return;
69
97
  probing = true;
98
+ let found = false;
70
99
  try {
71
100
  const res = await fetch("/api/local-link", { credentials: "same-origin", cache: "no-store" });
72
101
  if (!res.ok) return;
@@ -74,11 +103,14 @@ async function attempt() {
74
103
  if (!Number.isInteger(body.port) || typeof body.ticket !== "string") return;
75
104
  if (!(await probe(body.port as number, body.ticket))) return;
76
105
  localBase = `ws://127.0.0.1:${body.port}/ws?token=${encodeURIComponent(body.ticket)}`;
106
+ found = true;
107
+ retries = 0;
77
108
  onFound?.();
78
109
  } catch {
79
110
  /* no fast path; the tunnel works as before */
80
111
  } finally {
81
112
  probing = false;
113
+ if (!found) scheduleRetry();
82
114
  }
83
115
  }
84
116
 
@@ -90,12 +122,11 @@ export function startLocalLink(found: () => void) {
90
122
  void attempt();
91
123
  }
92
124
 
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.
125
+ // A terminal socket to the fast path closed before it opened (the daemon is
126
+ // restarting, or stopped): use the tunnel meanwhile, and look again shortly.
96
127
  export function localLinkFailed() {
97
128
  if (!localBase) return;
98
129
  localBase = null;
99
- window.clearTimeout(retryTimer);
100
- retryTimer = window.setTimeout(() => void attempt(), RETRY_AFTER_FAILURE_MS);
130
+ retries = 0;
131
+ scheduleRetry();
101
132
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { isLocalLinkUrl, localLinkEligible } from "../src/utils/localLink";
2
+ import { currentWsBase, isLocalLinkUrl, localLinkEligible, noteSocketClosed } from "../src/utils/localLink";
3
3
 
4
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
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";
@@ -21,4 +21,10 @@ describe("same-machine fast path", () => {
21
21
  expect(isLocalLinkUrl("ws://127.0.0.1:13575/ws?token=ll_x&room=a")).toBe(true);
22
22
  expect(isLocalLinkUrl("wss://hop.zhoulab.io/ws?room=a")).toBe(false);
23
23
  });
24
+ it("hands tiles the tunnel base while there is no fast path", () => {
25
+ expect(currentWsBase("wss://hop.zhoulab.io/ws")).toBe("wss://hop.zhoulab.io/ws");
26
+ // A tunnel socket that never opened says nothing about the fast path.
27
+ noteSocketClosed("wss://hop.zhoulab.io/ws?room=a", false);
28
+ expect(currentWsBase("wss://hop.zhoulab.io/ws")).toBe("wss://hop.zhoulab.io/ws");
29
+ });
24
30
  });
package/hop CHANGED
@@ -976,6 +976,18 @@ ensurePrivateDir(LOG_DIR);
976
976
  ensurePrivateDir(WORKSPACES_DIR);
977
977
  ensurePrivateDir(RUNTIME_DATA_DIR);
978
978
 
979
+ // A session name made from free text (a Codex thread title, a display name
980
+ // from before names were checked): runs of anything outside the name
981
+ // alphabet become one dash, accents fold to their letters, and an empty
982
+ // result falls back. "Find grant opportunities and draft" →
983
+ // "Find-grant-opportunities-and-draft".
984
+ function sessionNameFromText(text, fallback = 'session', max = 40) {
985
+ const slug = String(text || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
986
+ .replace(/[^A-Za-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, '')
987
+ .slice(0, max).replace(/[-_]+$/, '');
988
+ return slug || fallback;
989
+ }
990
+
979
991
  function toInternalSessionName(displayName) {
980
992
  if (!displayName || typeof displayName !== 'string') return null;
981
993
  if (!SESSION_NAME_RE.test(displayName)) return null;
@@ -1422,7 +1434,11 @@ function isDisplayNameInUse(displayName) {
1422
1434
  // the internal name so it always renames the session the user clicked.
1423
1435
  function renameSessionDisplayName(oldDisplayName, newDisplayName, explicitInternalName) {
1424
1436
  const resolvedOldDisplayName = resolveDisplayNameAlias(oldDisplayName);
1425
- if (!SESSION_NAME_RE.test(resolvedOldDisplayName)) {
1437
+ // A session addressed by its internal name can always be renamed — that
1438
+ // is how one carrying a malformed old name (spaces, from a Codex title)
1439
+ // gets a valid one.
1440
+ const explicitOk = !!(explicitInternalName && SESSION_NAME_RE.test(explicitInternalName));
1441
+ if (!explicitOk && !SESSION_NAME_RE.test(resolvedOldDisplayName)) {
1426
1442
  const err = new Error('Invalid old name');
1427
1443
  err.status = 400;
1428
1444
  throw err;
@@ -10651,24 +10667,30 @@ function isDirectLoopbackRequest(req) {
10651
10667
  // can open a WebSocket to 127.0.0.1 with it, attaches there instead. A ticket
10652
10668
  // stands in for the login that asked for it and only on a DIRECT loopback
10653
10669
  // connection, so a copy is worthless off this machine; revoking the login
10654
- // ends its tickets too. Kept in memory: a daemon restart drops them, and the
10655
- // page falls back to the tunnel and asks again.
10656
- const LOCAL_LINK_TTL_MS = 12 * 3600 * 1000;
10657
- const localLinkTickets = new Map(); // ticket -> { token, expiresAt }
10670
+ // ends its tickets too. Signed rather than remembered: a ticket names the
10671
+ // login and its expiry under an HMAC keyed by the daemon's persistent
10672
+ // secret, so it outlives a daemon restart. (Kept in memory, every restart
10673
+ // knocked each tab off the fast path onto the tunnel and, half a minute
10674
+ // later, back again — two drops per deploy.)
10675
+ const LOCAL_LINK_TTL_MS = 7 * 24 * 3600 * 1000;
10676
+ function localLinkSignature(body) {
10677
+ return crypto.createHmac('sha256', String(SESSION_SECRET)).update('local-link:' + body).digest('base64url');
10678
+ }
10658
10679
  function issueLocalLinkTicket(sessionToken) {
10659
- const now = Date.now();
10660
- for (const [t, rec] of localLinkTickets) if (rec.expiresAt <= now) localLinkTickets.delete(t);
10661
- while (localLinkTickets.size >= 500) localLinkTickets.delete(localLinkTickets.keys().next().value);
10662
- const ticket = 'll_' + crypto.randomBytes(24).toString('base64url');
10663
- localLinkTickets.set(ticket, { token: sessionToken, expiresAt: now + LOCAL_LINK_TTL_MS });
10664
- return ticket;
10680
+ const session = authSessions.verify(sessionToken);
10681
+ if (!session) return null;
10682
+ const body = `${session.id}.${(Date.now() + LOCAL_LINK_TTL_MS).toString(36)}`;
10683
+ return `ll_${body}.${localLinkSignature(body)}`;
10665
10684
  }
10666
10685
  function verifyLocalLinkTicket(ticket) {
10667
- const rec = localLinkTickets.get(ticket);
10668
- if (!rec) return null;
10669
- const session = rec.expiresAt > Date.now() ? authSessions.verify(rec.token) : null;
10670
- if (!session) localLinkTickets.delete(ticket);
10671
- return session;
10686
+ const m = /^ll_([A-Za-z0-9_-]+)\.([0-9a-z]+)\.([A-Za-z0-9_-]+)$/.exec(String(ticket || ''));
10687
+ if (!m) return null;
10688
+ const body = `${m[1]}.${m[2]}`;
10689
+ const want = Buffer.from(localLinkSignature(body));
10690
+ const got = Buffer.from(m[3]);
10691
+ if (want.length !== got.length || !crypto.timingSafeEqual(want, got)) return null;
10692
+ if (parseInt(m[2], 36) <= Date.now()) return null;
10693
+ return authSessions.list().find((s) => s.id === m[1]) || null;
10672
10694
  }
10673
10695
 
10674
10696
  // Every way a request can prove itself, in one place.
@@ -12593,7 +12615,13 @@ happens until someone clicks it.
12593
12615
  // says "node" and cannot), or a plain shell.
12594
12616
  const turn = readTurnInfo(source);
12595
12617
  const codexId = turn?.agent === 'codex' ? codexRootThreadId(turn.sessionId) : null;
12596
- const runsCodex = !!codexId
12618
+ // A session continued from the Codex app names its thread
12619
+ // in its record from the start; the turn file only comes
12620
+ // with its first finished turn in hop, and until then the
12621
+ // session read as a plain shell ("no claude or codex
12622
+ // conversation to hand off").
12623
+ const codexAppId = /^[0-9a-f-]{36}$/.test(String(meta?.fromCodexApp || '')) ? meta.fromCodexApp : null;
12624
+ const runsCodex = !!codexId || !!codexAppId
12597
12625
  || /codex/i.test(String(config.foregroundProcess || ''))
12598
12626
  || /codex/i.test(String(getRuntimeSessionMetadata(source)?.foregroundProcess || ''));
12599
12627
  const sourceKind = rec ? 'claude' : runsCodex ? 'codex' : 'shell';
@@ -12618,15 +12646,21 @@ happens until someone clicks it.
12618
12646
  return;
12619
12647
  }
12620
12648
  const codexRollout = sourceKind === 'codex'
12621
- ? ((codexId && findCodexRolloutById(codexId)) || resolveCodexTranscriptByCwd(cwd))
12649
+ // The app thread itself, not hop's fork of it: a fork's
12650
+ // rollout points back at its parent instead of copying
12651
+ // the history, so it alone reads as an empty conversation.
12652
+ ? ((codexId && findCodexRolloutById(codexId)) || (codexAppId && findCodexRolloutById(codexAppId)) || resolveCodexTranscriptByCwd(cwd))
12622
12653
  : null;
12623
12654
 
12624
12655
  const sourceDisplay = getSessionDisplayName(source) || source;
12625
12656
  const suffix = !handoff ? '-fork' : wantTarget === sourceKind ? '-cont' : '-' + wantTarget;
12626
- let forkName = sourceDisplay + suffix;
12657
+ // The fork's name is also its internal name, so it must be
12658
+ // a valid one even when the source's display name is not.
12659
+ const forkBase = SESSION_NAME_RE.test(sourceDisplay) ? sourceDisplay : sessionNameFromText(sourceDisplay);
12660
+ let forkName = forkBase + suffix;
12627
12661
  let n = 2;
12628
12662
  while (isDisplayNameInUse(forkName) || hopSessionExists(toInternalSessionName(forkName))) {
12629
- forkName = sourceDisplay + suffix + n;
12663
+ forkName = forkBase + suffix + n;
12630
12664
  n += 1;
12631
12665
  }
12632
12666
  const forkInternal = toInternalSessionName(forkName);
@@ -12653,7 +12687,7 @@ happens until someone clicks it.
12653
12687
  const stage = path.join(HOME_DIR, 'uploads', forkInternal);
12654
12688
  fs.mkdirSync(stage, { recursive: true, mode: 0o700 });
12655
12689
  const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
12656
- const extractPath = path.join(stage, `handoff-from-${sourceDisplay}-${stamp}.md`);
12690
+ const extractPath = path.join(stage, `handoff-from-${forkBase}-${stamp}.md`);
12657
12691
  writePrivateFile(extractPath, result.text);
12658
12692
  const bytes = Buffer.byteLength(result.text);
12659
12693
  const prompt = trajectoryExtract.handoffPrompt(extractPath, bytes, sourceKind);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.82",
3
+ "version": "0.11.83",
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": {
@@ -16,6 +16,7 @@
16
16
  // Streams line by line: real transcripts run to hundreds of MB.
17
17
 
18
18
  const fs = require('fs');
19
+ const path = require('path');
19
20
  const readline = require('readline');
20
21
 
21
22
  const NOISE_PREFIXES = [
@@ -170,10 +171,36 @@ async function codexRolloutId(file) {
170
171
  * character budget fills — the most recent context is what the receiver
171
172
  * needs — then harness noise is dropped.
172
173
  */
174
+ // Every file of one Codex thread, oldest first. Codex starts a new segment
175
+ // file (rollout-<time>-<thread>_<segment>.jsonl) partway through a long
176
+ // thread, so the newest file alone is only its last stretch — a handoff from
177
+ // it carried the final hour of a two-day conversation. Segments live in the
178
+ // day folders under the same sessions/ root.
179
+ function codexThreadSegments(file) {
180
+ const id = (/rollout-[0-9T-]+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/.exec(path.basename(file)) || [])[1];
181
+ const sessions = path.resolve(path.dirname(file), '..', '..', '..');
182
+ if (!id || path.basename(sessions) !== 'sessions') return [file];
183
+ const found = [];
184
+ const list = (dir) => { try { return fs.readdirSync(dir); } catch { return []; } };
185
+ for (const y of list(sessions)) for (const m of list(path.join(sessions, y))) for (const d of list(path.join(sessions, y, m))) {
186
+ for (const n of list(path.join(sessions, y, m, d))) {
187
+ if (n.endsWith('.jsonl') && n.includes(`-${id}`)) found.push(path.join(sessions, y, m, d, n));
188
+ }
189
+ }
190
+ found.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
191
+ return found.includes(file) ? found : [file];
192
+ }
193
+
173
194
  async function extractConversation(file, { maxChars = 800000, tool } = {}) {
174
195
  const kind = tool || detectTool(file);
175
196
  if (kind === 'gemini') throw new Error('Gemini transcripts are not supported for handoff');
176
- const turns = kind === 'codex' ? await readCodexTurns(file) : await readClaudeTurns(file);
197
+ let turns;
198
+ if (kind === 'codex') {
199
+ turns = [];
200
+ for (const f of codexThreadSegments(file)) turns.push(...await readCodexTurns(f));
201
+ } else {
202
+ turns = await readClaudeTurns(file);
203
+ }
177
204
  const selected = [];
178
205
  let total = 0;
179
206
  for (let i = turns.length - 1; i >= 0; i--) {
@@ -207,6 +207,8 @@ async function startDaemon() {
207
207
  ...process.env,
208
208
  HOP_HOME: hopHome,
209
209
  HOP_NO_TUNNEL: '1',
210
+ // Never the real ~/.codex: tests plant their own rollouts here.
211
+ CODEX_HOME: path.join(tempDir, 'codex_home'),
210
212
  PATH: `${binDir}:${process.env.PATH || ''}`,
211
213
  CLOUDFLARED_LOG: cloudflaredLog
212
214
  };
@@ -1027,6 +1029,84 @@ test('condense into the same agent hands off the trajectory to a fresh session',
1027
1029
  });
1028
1030
 
1029
1031
 
1032
+ // A session continued from the Codex app used to carry the thread's title
1033
+ // verbatim ("Find grant opportunities and draft") — spaces a session name
1034
+ // may not hold, so renaming it and Continue-in both failed. Such a session
1035
+ // can still be renamed by its internal name and forked under a valid name.
1036
+ test('a session whose display name has spaces can be renamed and forked', async () => {
1037
+ const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/terminals', {
1038
+ name: 'spacedsrc',
1039
+ cwd: tempDir
1040
+ }, agentHeaders);
1041
+ assert.equal(create.status, 200);
1042
+ const internal = create.data.sessionName;
1043
+ const metaPath = path.join(hopHome, 'claude-sessions', `${internal}.meta`);
1044
+ const meta = JSON.parse(await fs.readFile(metaPath, 'utf8').catch(() => '{}'));
1045
+ await fs.writeFile(metaPath, JSON.stringify({ ...meta, internalName: internal, displayName: 'Find grant opportunities' }), { mode: 0o600 });
1046
+ await fs.writeFile(path.join(hopHome, 'claude-sessions', `${internal}.json`), JSON.stringify({
1047
+ sessionId: '22222222-3333-4444-5555-666666666666', cwd: tempDir, launchCmd: 'claude',
1048
+ updatedAt: new Date().toISOString()
1049
+ }), { mode: 0o600 });
1050
+
1051
+ const before = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
1052
+ assert.equal(before.data.sessions.find((s) => s.internalName === internal)?.displayName, 'Find grant opportunities');
1053
+
1054
+ const fork = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/fork', {
1055
+ internalName: internal
1056
+ });
1057
+ assert.equal(fork.status, 200, JSON.stringify(fork.data));
1058
+ assert.match(fork.data.internalName, /^[A-Za-z0-9_-]+$/);
1059
+
1060
+ const rename = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename', {
1061
+ oldName: 'Find grant opportunities', newName: 'grant-search', internalName: internal
1062
+ });
1063
+ assert.equal(rename.status, 200, JSON.stringify(rename.data));
1064
+
1065
+ for (const name of [internal, fork.data.internalName]) {
1066
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
1067
+ { name, internalName: name });
1068
+ }
1069
+ });
1070
+
1071
+
1072
+ // A session continued from the Codex app has no turn record until its first
1073
+ // turn finishes in hop — and until then Continue-in-Claude refused it as a
1074
+ // plain shell. Its record names the app thread, and that thread's rollout
1075
+ // is the conversation to hand off (hop's own fork of it holds only a pointer).
1076
+ test('a Codex-app session hands off to claude before its first turn in hop', async () => {
1077
+ const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/terminals', {
1078
+ name: 'codexappsrc',
1079
+ cwd: tempDir
1080
+ }, agentHeaders);
1081
+ assert.equal(create.status, 200);
1082
+ const internal = create.data.sessionName;
1083
+ const threadId = '01a14846-0000-7000-8000-00000000abcd';
1084
+ const day = path.join(daemonEnv.CODEX_HOME, 'sessions', '2026', '10', '18');
1085
+ await fs.mkdir(day, { recursive: true });
1086
+ await fs.writeFile(path.join(day, `rollout-2026-10-18T10-00-00-${threadId}.jsonl`), [
1087
+ { type: 'session_meta', timestamp: '2026-10-18T10:00:00Z', payload: { id: threadId, cwd: tempDir } },
1088
+ { type: 'event_msg', timestamp: '2026-10-18T10:00:01Z', payload: { type: 'user_message', message: 'find grant opportunities for the lab' } },
1089
+ { type: 'response_item', timestamp: '2026-10-18T10:00:05Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'NSF MCB fits best.' }] } }
1090
+ ].map((e) => JSON.stringify(e)).join('\n') + '\n');
1091
+ const metaPath = path.join(hopHome, 'claude-sessions', `${internal}.meta`);
1092
+ const meta = JSON.parse(await fs.readFile(metaPath, 'utf8').catch(() => '{}'));
1093
+ await fs.writeFile(metaPath, JSON.stringify({ ...meta, internalName: internal, fromCodexApp: threadId }), { mode: 0o600 });
1094
+
1095
+ const handoff = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/fork', {
1096
+ internalName: internal, target: 'claude'
1097
+ });
1098
+ assert.equal(handoff.status, 200, JSON.stringify(handoff.data));
1099
+ assert.equal(handoff.data.kind, 'handoff');
1100
+ const extract = await fs.readFile(handoff.data.extract.path, 'utf8');
1101
+ assert.match(extract, /find grant opportunities for the lab/);
1102
+
1103
+ for (const name of [internal, handoff.data.internalName]) {
1104
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
1105
+ { name, internalName: name });
1106
+ }
1107
+ });
1108
+
1109
+
1030
1110
  // The briefing generator's coverage signal: the server witnesses who was
1031
1111
  // looking at what, and when. Only a HUMAN-viewing attach may stamp it —
1032
1112
  // wall tiles are spectators and terminal-api clients are agents.
@@ -65,6 +65,35 @@ test('codex rollout: user turns deduped, assistant paired, id readable', async (
65
65
  assert.equal(await codexRolloutId(file), 'abc-123');
66
66
  });
67
67
 
68
+ // A long Codex thread continues in a segment file in a later day folder;
69
+ // the handoff reads the thread from its first file, not just the newest.
70
+ test('codex thread split across segment files is read whole, oldest first', async () => {
71
+ const dir = tmp();
72
+ const id = '01a14846-f12b-79b0-a607-19de9fdc0dbf';
73
+ const day1 = path.join(dir, '.codex', 'sessions', '2026', '10', '17');
74
+ const day2 = path.join(dir, '.codex', 'sessions', '2026', '10', '18');
75
+ fs.mkdirSync(day1, { recursive: true });
76
+ fs.mkdirSync(day2, { recursive: true });
77
+ const first = path.join(day1, `rollout-2026-10-17T00-12-51-${id}.jsonl`);
78
+ const later = path.join(day2, `rollout-2026-10-18T21-44-03-${id}_01a1520b-6e45-7560-af50-845d3f9983d4.jsonl`);
79
+ jsonl(first, [
80
+ { type: 'session_meta', timestamp: '2026-10-17T00:12:51.000Z', payload: { id, cwd: '/Users/me/grants' } },
81
+ { type: 'event_msg', timestamp: '2026-10-17T00:13:00.000Z', payload: { type: 'user_message', message: 'find grant opportunities' } }
82
+ ]);
83
+ jsonl(later, [
84
+ { type: 'session_meta', timestamp: '2026-10-18T21:44:03.000Z', payload: { id, cwd: '/Users/me/grants' } },
85
+ { type: 'event_msg', timestamp: '2026-10-18T21:45:00.000Z', payload: { type: 'user_message', message: 'draft the PO email' } }
86
+ ]);
87
+ // An unrelated thread in the same folder stays out.
88
+ jsonl(path.join(day2, 'rollout-2026-10-18T09-00-00-11111111-2222-3333-4444-555555555555.jsonl'), [
89
+ { type: 'event_msg', timestamp: '2026-10-18T09:00:01.000Z', payload: { type: 'user_message', message: 'unrelated work' } }
90
+ ]);
91
+ const r = await extractConversation(later, { tool: 'codex' });
92
+ assert.equal(r.totalTurns, 2);
93
+ assert.ok(r.text.indexOf('find grant opportunities') < r.text.indexOf('draft the PO email'));
94
+ assert.ok(!r.text.includes('unrelated work'));
95
+ });
96
+
68
97
  test('handoff prompt names the file, its size, and the source tool', () => {
69
98
  const p = handoffPrompt('/x/handoff.md', 5 * 1024, 'claude');
70
99
  assert.match(p, /Read the ENTIRE file \/x\/handoff\.md \(5KB\)/);