hop2 0.11.74 → 0.11.76

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/hop CHANGED
@@ -2733,6 +2733,20 @@ const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc
2733
2733
  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
2734
  // Trust-dialog reading lives in lib/trust-dialog.js so it can be unit-tested.
2735
2735
  const { trustDialogWorkspace, trustDialogAnswer, samePath } = require('./lib/trust-dialog.js');
2736
+ // The transport cloudflared uses to reach Cloudflare's edge. Its default is
2737
+ // QUIC (UDP): on networks that shape or drop UDP — campus networks often do —
2738
+ // a lost packet stalls every request in flight on that connection for a
2739
+ // retransmit timeout, and keystrokes from a phone or laptop arrive in
2740
+ // 1–3 s bursts while the host itself is idle (measured 2026-10-11: QUIC
2741
+ // connection 0 had 120 timeout losses; one request in four took 1.2–2.9 s).
2742
+ // "http2" runs the tunnel over TCP instead. Set `tunnel_protocol` in
2743
+ // ~/.hop2 config ("quic" | "http2" | "auto") or HOP_TUNNEL_PROTOCOL.
2744
+ function getTunnelProtocol() {
2745
+ const raw = String(process.env.HOP_TUNNEL_PROTOCOL || getHopConfig().tunnel_protocol || '').toLowerCase();
2746
+ return raw === 'quic' || raw === 'http2' || raw === 'auto' ? raw : null;
2747
+ }
2748
+ const tunnelProtocolLine = () => { const p = getTunnelProtocol(); return p ? `protocol: ${p}\n` : ''; };
2749
+
2736
2750
  function getResumeMode() {
2737
2751
  const mode = String(getHopConfig().resume_mode || '').toLowerCase();
2738
2752
  return mode === 'summary' || mode === 'ask' ? mode : 'full';
@@ -6021,6 +6035,20 @@ function runShellProbe(command, cwd) {
6021
6035
  // agent-turn path has always sent them as separate writes; this is that.
6022
6036
  const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
6023
6037
  // Is the message still sitting in the composer (the `›` line) — typed, not sent?
6038
+ /**
6039
+ * Is this session's agent up and showing its own UI — the only thing a
6040
+ * check-back may type into? Not running, a plain shell, a trust or resume
6041
+ * dialog, or an unreadable screen are all "no".
6042
+ */
6043
+ async function agentScreenReady(entry) {
6044
+ if (!entry || entry.live !== true) return { ok: false, reason: 'waiting: the session is not running' };
6045
+ const verdict = await roomIsRedoable(entry);
6046
+ // roomIsRedoable says "not redoable, and here is whether the agent is
6047
+ // idle" exactly when an agent's UI owns the screen.
6048
+ if (!verdict.ok && verdict.agentIdle !== undefined) return { ok: true };
6049
+ return { ok: false, reason: `waiting: no agent on screen (${verdict.reason})` };
6050
+ }
6051
+
6024
6052
  async function composerStillHolds(internalName, message) {
6025
6053
  try {
6026
6054
  const hostRes = await callExternalHayHostApi(`/rooms/${encodeURIComponent(internalName)}/serialized`);
@@ -6100,6 +6128,17 @@ async function runCheckbackSweep() {
6100
6128
  continue;
6101
6129
  }
6102
6130
  if (!checkbacks.mayDeliver(cb, ctx)) continue; // mid-turn: next sweep
6131
+ // Type only into a RUNNING agent. Attaching to a stopped
6132
+ // session creates its room — as a bare shell — and the
6133
+ // message then ran as shell commands ("zsh: command not
6134
+ // found: Collect"); worse, that shell made restore think the
6135
+ // session was up and skip it (surf, alphagenome, 2026-10-12
6136
+ // after a reboot). Wait until the agent's own UI is on screen.
6137
+ const ready = await agentScreenReady(entry);
6138
+ if (!ready.ok) {
6139
+ if (cb.lastError !== ready.reason) { cb.lastError = ready.reason; dirty = true; }
6140
+ continue;
6141
+ }
6103
6142
  const e2 = await attachTerminalApiClient(cb.session, entry.displayName || cb.session, { actor: 'system', label: 'checkback', typeOnly: true });
6104
6143
  try { await typeAndSubmit(e2, cb.session, cb.message); }
6105
6144
  finally { try { closeTerminalApiEntry(e2.id); } catch (e) { /* closed */ } }
@@ -6384,8 +6423,13 @@ function rebuildClaudeLaunch(launchCmd) {
6384
6423
  if (!valued && out.includes(t)) continue;
6385
6424
  out.push(t);
6386
6425
  if (valued) out.push(tokens[++i]);
6426
+ continue;
6387
6427
  }
6388
- // bare positional (initial prompt) — dropped
6428
+ // A bare positional is the initial PROMPT, and everything after it
6429
+ // is prompt text too — split on spaces, its words included things
6430
+ // like "--source-ref" and "-q", which were kept as flags and made
6431
+ // claude exit at once on every restore (pulse-1004-claude-o3).
6432
+ break;
6389
6433
  }
6390
6434
  return out.join(' ');
6391
6435
  }
@@ -6965,6 +7009,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6965
7009
  const { resolved, unknown } = resolveRestoreTargets(only, snapshot);
6966
7010
  for (const name of unknown) skipped.push({ name, reason: 'no such session' });
6967
7011
  onlySet = resolved;
7012
+ let closedAny = false;
6968
7013
  for (const internalName of resolved) {
6969
7014
  if (!live.has(internalName)) continue;
6970
7015
  const entry = snapshot.entries.find((e) => e.internalName === internalName);
@@ -6999,7 +7044,13 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6999
7044
  }
7000
7045
  log(` ${display}: closed the ${verdict.reason} left by the last restore`, DIM);
7001
7046
  live.delete(internalName);
7047
+ closedAny = true;
7002
7048
  }
7049
+ // The keeper ends the old terminal a beat AFTER the room closes. A
7050
+ // relaunch inside that beat was refused ("already running"), and
7051
+ // then the old one exited too — both sessions vanished (alphagenome,
7052
+ // angler-codex-claude-codex, 2026-10-12). Let the old ones go first.
7053
+ if (closedAny) await new Promise((r) => setTimeout(r, 2000));
7003
7054
  }
7004
7055
  const candidates = snapshot.entries.filter((e) => e && e.type === 'terminal' && !live.has(e.internalName));
7005
7056
  // Primary source: the SessionStart-hook records. Add any recorded session that
@@ -13320,7 +13371,11 @@ happens until someone clicks it.
13320
13371
  const result = await restoreSavedSessions({
13321
13372
  dryRun: parsed.dryRun === true,
13322
13373
  includeAgents: parsed.includeAgents === true,
13323
- only: Array.isArray(parsed.only) ? parsed.only.filter((n) => typeof n === 'string').slice(0, 200) : null
13374
+ only: Array.isArray(parsed.only) ? parsed.only.filter((n) => typeof n === 'string').slice(0, 200) : null,
13375
+ // Restart named agents that sit idle at their prompt
13376
+ // (never one mid-turn) — e.g. one resumed the wrong
13377
+ // conversation. Only together with `only`.
13378
+ replaceIdleAgent: parsed.replaceIdleAgent === true && Array.isArray(parsed.only)
13324
13379
  });
13325
13380
  res.writeHead(200, { 'Content-Type': 'application/json' });
13326
13381
  res.end(JSON.stringify(result));
@@ -14497,7 +14552,24 @@ happens until someone clicks it.
14497
14552
  return;
14498
14553
  }
14499
14554
  await reconcileExternalRuntimeSessions();
14500
- await ensureExternalRuntimeSession(sessionName, sessionConfig?.cwd, getSessionDisplayName(sessionName), {
14555
+ // A dead session that holds an agent conversation comes
14556
+ // back AS that agent, whoever opens it — a viewer, a
14557
+ // check-back, a tile. Creating a bare shell here left
14558
+ // surf a zsh prompt after a reboot (a check-back's attach
14559
+ // beat restore by two seconds, and restore then skipped
14560
+ // the "running" session). Same plan restore would use.
14561
+ let revive = null;
14562
+ if (!sessionConfig?.startup && !(await runtimeTerminalSessionExists(sessionName))) {
14563
+ try {
14564
+ const plan = restorePlanForSession({ internalName: sessionName, cwd: sessionConfig?.cwd, startup: sessionConfig?.startup, autoStart: sessionConfig?.autoStart });
14565
+ if (plan?.command) {
14566
+ revive = plan;
14567
+ log(`[ws] ${sessionName}: not running — starting it as its agent (${plan.command.slice(0, 80)})`, DIM);
14568
+ }
14569
+ } catch (e) { /* no plan: a plain shell, as before */ }
14570
+ }
14571
+ await ensureExternalRuntimeSession(sessionName, revive?.cwd || sessionConfig?.cwd, getSessionDisplayName(sessionName), {
14572
+ command: revive?.command || undefined,
14501
14573
  shell: sessionConfig?.shell,
14502
14574
  env: sessionConfig?.env,
14503
14575
  cols: sessionConfig?.cols,
@@ -14729,7 +14801,7 @@ function startTunnel(isRestart = false) {
14729
14801
  const tunnelConfig = `
14730
14802
  url: http://${localTunnelHost}:${PROXY_PORT}
14731
14803
  tunnel: ${domainConfig.tunnelName}
14732
- `;
14804
+ ${tunnelProtocolLine()}`;
14733
14805
  writePrivateFile(tunnelConfigPath, tunnelConfig);
14734
14806
 
14735
14807
  const runArgs = ['tunnel', '--config', tunnelConfigPath, 'run'];
@@ -14907,7 +14979,7 @@ function startClientTunnel(isRestart = false) {
14907
14979
  url: http://127.0.0.1:${PROXY_PORT}
14908
14980
  tunnel: ${tunnelId}
14909
14981
  credentials-file: ${path.resolve(credentialsPath)}
14910
- `;
14982
+ ${tunnelProtocolLine()}`;
14911
14983
  writePrivateFile(tunnelConfigPath, tunnelConfig);
14912
14984
 
14913
14985
  const tunnel = spawn('cloudflared', ['tunnel', '--config', tunnelConfigPath, 'run'], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.74",
3
+ "version": "0.11.76",
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
@@ -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', () => {