hop2 0.11.45 → 0.11.46

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
@@ -2650,21 +2650,8 @@ const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc
2650
2650
  // sat at the dialog for two weeks (lightscope, Neptune). Whitespace-
2651
2651
  // tolerant because the grid text can run words together across wraps.
2652
2652
  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;
2653
- // The directory the dialog is asking about, so an answer is only ever given
2654
- // for the workspace the session was launched in - never for wherever a
2655
- // shell happened to wander.
2656
- function trustDialogWorkspace(screenText) {
2657
- const m = /Accessing\s*workspace:?\s*(\/[^\s\u2502\u2500]+)/i.exec(String(screenText || ''));
2658
- return m ? m[1].replace(/\/+$/, '') : null;
2659
- }
2660
- function samePath(a, b) {
2661
- const norm = (p) => {
2662
- let v = String(p || '').replace(/\/+$/, '');
2663
- try { v = fs.realpathSync(v); } catch (e) { /* keep as-is */ }
2664
- return v;
2665
- };
2666
- return !!a && !!b && norm(a) === norm(b);
2667
- }
2653
+ // Trust-dialog reading lives in lib/trust-dialog.js so it can be unit-tested.
2654
+ const { trustDialogWorkspace, trustDialogAnswer, samePath } = require('./lib/trust-dialog.js');
2668
2655
  function getResumeMode() {
2669
2656
  const mode = String(getHopConfig().resume_mode || '').toLowerCase();
2670
2657
  return mode === 'summary' || mode === 'ask' ? mode : 'full';
@@ -6109,7 +6096,34 @@ async function sweepRestoreReconcile() {
6109
6096
  // The screen and foreground name lie for argv-launched sessions.
6110
6097
  const procUp = claudeProcessRunning(exp.sessionId);
6111
6098
  const running = !isShellProcName(entry.foregroundProcess);
6112
- const claudeUp = procUp || running || CLAUDE_UI_RE.test(text);
6099
+ const alive = procUp || running;
6100
+ // A trust dialog is only a dialog while claude is up to show it.
6101
+ // Checked BEFORE "converged": a live claude parked at the dialog
6102
+ // used to be declared running and left there forever, while the
6103
+ // dialog's text lingering on a dead session's screen — the
6104
+ // process gone, a shell prompt under it — was "answered" every
6105
+ // tick, typing digits into the shell (motif, 2026-09-10).
6106
+ const settledMs = now - Math.max(exp.lastDriveAt || 0, exp.recordedAt || 0);
6107
+ if (TRUST_PROMPT_RE.test(text) && alive && settledMs >= 6000) {
6108
+ const trust = trustDialogAnswer(text, exp.cwd);
6109
+ if (trust.ours) {
6110
+ if (!exp.trustAnsweredAt || now - exp.trustAnsweredAt > 20_000) {
6111
+ try {
6112
+ const e2 = await attachTerminalApiClient(name, exp.displayName, { actor: 'system', label: 'reconcile' });
6113
+ try { sendTerminalInput(e2, trust.input); } finally { try { closeTerminalApiEntry(e2.id); } catch (e) {} }
6114
+ exp.trustAnsweredAt = now;
6115
+ changed = true;
6116
+ log(`[reconcile] ${exp.displayName}: accepted ${trust.preapproval ? 'pre-approved permissions' : 'workspace trust'} (${trust.how})`, DIM);
6117
+ } catch (e) { /* try again next tick */ }
6118
+ }
6119
+ continue;
6120
+ }
6121
+ // Trust dialog for some OTHER directory — not ours to answer.
6122
+ exp.status = 'needs-attention';
6123
+ exp.reason = `claude is asking to trust ${trust.asked || 'a directory'}, not ${exp.cwd}`;
6124
+ changed = true;
6125
+ continue;
6126
+ const claudeUp = alive || CLAUDE_UI_RE.test(text);
6113
6127
  if (claudeUp) {
6114
6128
  exp.status = 'converged';
6115
6129
  exp.convergedAt = now;
@@ -6117,23 +6131,6 @@ async function sweepRestoreReconcile() {
6117
6131
  log(`[reconcile] ${exp.displayName}: verified running`, DIM);
6118
6132
  continue;
6119
6133
  }
6120
- // A dialog: the resume sweep answers resume prompts already; the
6121
- // trust dialog we answer here (only for this session's own cwd).
6122
- if (TRUST_PROMPT_RE.test(text)) {
6123
- const asked = trustDialogWorkspace(text);
6124
- if (asked && samePath(asked, exp.cwd)) {
6125
- try {
6126
- const e2 = await attachTerminalApiClient(name, exp.displayName, { actor: 'system', label: 'reconcile' });
6127
- try { sendTerminalInput(e2, '1\r'); } finally { try { closeTerminalApiEntry(e2.id); } catch (e) {} }
6128
- log(`[reconcile] ${exp.displayName}: accepted workspace trust`, DIM);
6129
- } catch (e) { /* try again next tick */ }
6130
- continue;
6131
- }
6132
- // Trust dialog for some OTHER directory — not ours to answer.
6133
- exp.status = 'needs-attention';
6134
- exp.reason = `claude is asking to trust ${asked || 'a directory'}, not ${exp.cwd}`;
6135
- changed = true;
6136
- continue;
6137
6134
  }
6138
6135
  if (RESUME_PROMPT_RE.test(text)) continue; // resume sweep owns this
6139
6136
  // A busy non-shell foreground we did not recognise as claude: the
@@ -6560,18 +6557,32 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6560
6557
  const grid = await getRoomGridPreviewShared(job.plan.internalName);
6561
6558
  screen = String(grid?.text || '');
6562
6559
  } catch (e) { continue; }
6563
- if (TRUST_PROMPT_RE.test(screen) && !job.trustAnswered) {
6560
+ // Only a LIVE dialog: the same text lingers on screen after
6561
+ // claude has exited (or before the new one has drawn), and
6562
+ // answering it then types into a shell or into a claude that
6563
+ // has not asked yet.
6564
+ // ...and only once the fresh claude has had time to DRAW it:
6565
+ // the previous claude's dialog text is still on screen for the
6566
+ // first seconds after launch, and an answer sent then reaches
6567
+ // a claude that has not asked yet (the Enter lands on whatever
6568
+ // it shows next — for this dialog, the default "No, exit").
6569
+ const dialogLive = TRUST_PROMPT_RE.test(screen) && Date.now() - job.since >= 6000 && (() => {
6570
+ const recId = readClaudeSessionRecord(job.plan.internalName)?.sessionId;
6571
+ const fg = getRuntimeSessionMetadata(job.plan.internalName)?.foregroundProcess;
6572
+ return (!!recId && claudeProcessRunning(recId)) || (!!fg && !isShellProcName(fg));
6573
+ })();
6574
+ if (dialogLive && !job.trustAnswered) {
6564
6575
  // Answer only for the workspace this session was launched
6565
6576
  // in. A dialog naming any other directory is not ours to
6566
6577
  // wave through - leave it on screen and say so.
6567
- const asked = trustDialogWorkspace(screen);
6568
- if (asked && samePath(asked, job.plan.cwd)) {
6569
- try { sendTerminalInput(job.entry, '1\r'); } catch (e) { finish('write-failed'); continue; }
6570
- log(` ${getSessionDisplayName(job.plan.internalName) || job.plan.internalName}: accepted workspace trust for ${asked}`, DIM);
6578
+ const trust = trustDialogAnswer(screen, job.plan.cwd);
6579
+ if (trust.ours) {
6580
+ try { sendTerminalInput(job.entry, trust.input); } catch (e) { finish('write-failed'); continue; }
6581
+ log(` ${getSessionDisplayName(job.plan.internalName) || job.plan.internalName}: accepted ${trust.preapproval ? 'pre-approved permissions' : `workspace trust for ${trust.asked}`} (${trust.how})`, DIM);
6571
6582
  job.trustAnswered = true;
6572
6583
  job.since = Date.now();
6573
6584
  } else {
6574
- log(` ${getSessionDisplayName(job.plan.internalName) || job.plan.internalName}: claude is asking to trust ${asked || 'an unknown directory'}, not ${job.plan.cwd} — left for you to answer`, YELLOW);
6585
+ log(` ${getSessionDisplayName(job.plan.internalName) || job.plan.internalName}: claude is asking to trust ${trust.asked || 'an unknown directory'}, not ${job.plan.cwd} — left for you to answer`, YELLOW);
6575
6586
  finish('trust-prompt');
6576
6587
  }
6577
6588
  continue;
@@ -15362,7 +15373,7 @@ async function attachToAllSessions(existingTunnel) {
15362
15373
  const KNOWN_COMMANDS = [
15363
15374
  'client', 'start', 'attach', 'local', 'stop', 'status', 'doctor', 'logs',
15364
15375
  'help', 'config', 'url', 'qr', 'health', 'domain', 'domain-clear',
15365
- 'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'codex-notify', 'codex-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
15376
+ 'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'recover', 'update', 'resume-waiting', 'claude-hook', 'codex-notify', 'codex-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
15366
15377
  'mail', 'registration', 'canonical'
15367
15378
  ];
15368
15379
 
@@ -15485,6 +15496,7 @@ const HELP_SECTIONS = [
15485
15496
  ] },
15486
15497
  { title: 'Restore', lines: [
15487
15498
  `hop restore [--dry-run] [--agents] Recreate your saved sessions, resuming claude conversations (--agents includes agent-created ones)`,
15499
+ `hop recover [<session>] Bring back the agent that last ran here: inside a session, relaunch its conversation in this terminal`,
15488
15500
  `hop restore <name>... Redo just those sessions — even live ones, if they hold only an idle shell or a startup dialog`,
15489
15501
  `hop restore --health Show sessions the reconciler could not bring back on its own, and why`,
15490
15502
  `hop update [--check|--no-host] Self-update from GitHub (daemon + host restart, host waits for the fleet to go idle; --no-host skips the host cycle)`,
@@ -16096,6 +16108,61 @@ async function main() {
16096
16108
  // Handle `hop resume-waiting` — answer claude's resume question for any
16097
16109
  // session currently parked on it (sessions restored before this existed,
16098
16110
  // or restored with resume_mode=ask and since reconsidered).
16111
+ // hop recover [<session>] — bring back the agent that last ran in a
16112
+ // session. Inside a session with no argument, it relaunches that
16113
+ // session's own conversation IN THIS TERMINAL (the same flag-preserving
16114
+ // command `hop restore` would use), so a claude that exited — crash,
16115
+ // /exit, a wrong answer to a dialog — is one command away, and any
16116
+ // dialog it shows is answered by the human sitting there. Named from
16117
+ // anywhere, it is `hop restore <name>`.
16118
+ if (process.argv[2] === 'recover') {
16119
+ const args = process.argv.slice(3);
16120
+ const dryRun = args.includes('--dry-run') || args.includes('-n');
16121
+ const named = args.find((a) => !a.startsWith('-')) || null;
16122
+ const here = process.env.HOP_SESSION || null;
16123
+ if (!named && !here) {
16124
+ log(`Run ${CYAN}hop recover${RESET} inside a hop session, or name one: ${CYAN}hop recover <session>${RESET}`, YELLOW);
16125
+ process.exit(1);
16126
+ }
16127
+ const target = named ? (resolveSessionInternalName(named, named) || named) : here;
16128
+ if (target !== here) {
16129
+ // Not this terminal: the daemon relaunches it in its own room.
16130
+ const child = spawn(process.execPath, [__filename, 'restore', ...(dryRun ? ['--dry-run'] : []), named], { stdio: 'inherit' });
16131
+ child.on('exit', (code) => process.exit(code ?? 1));
16132
+ return;
16133
+ }
16134
+ const display = getSessionDisplayName(target) || target;
16135
+ const meta = readClaudeSessionMeta(target);
16136
+ const cfg = getSessionConfig(target);
16137
+ const cwd = meta?.cwd || cfg?.cwd || process.cwd();
16138
+ const rec = readClaudeSessionRecord(target);
16139
+ if (rec?.sessionId && claudeProcessRunning(rec.sessionId)) {
16140
+ log(`${display}: its claude conversation ${String(rec.sessionId).slice(0, 8)}… is already running in this session.`, YELLOW);
16141
+ process.exit(1);
16142
+ }
16143
+ const plan = restorePlanForSession({ internalName: target, cwd, startup: cfg?.startup, autoStart: cfg?.autoStart });
16144
+ let command = plan?.command || null;
16145
+ let kind = command ? 'claude' : null;
16146
+ if (!command) {
16147
+ // No claude record: a codex conversation is found by its rollout.
16148
+ const rollout = resolveCodexTranscriptByCwd(cwd);
16149
+ const rolloutId = rollout ? await trajectoryExtract.codexRolloutId(rollout) : null;
16150
+ if (rolloutId) {
16151
+ command = `codex resume '${rolloutId.replace(/'/g, `'\\''`)}'`;
16152
+ kind = 'codex';
16153
+ }
16154
+ }
16155
+ if (!command) {
16156
+ log(`${display}: nothing to recover — no claude or codex conversation is recorded for this session${plan?.warning ? ` (${plan.warning})` : ''}.`, YELLOW);
16157
+ process.exit(1);
16158
+ }
16159
+ log(`${dryRun ? 'Would recover' : 'Recovering'} ${BOLD}${display}${RESET} (${kind}) in this terminal:`, RESET);
16160
+ log(` ${DIM}${command}${RESET}`, RESET);
16161
+ if (dryRun) process.exit(0);
16162
+ const child = spawn('/bin/zsh', ['-lc', command], { stdio: 'inherit', cwd: plan?.cwd || cwd, env: process.env });
16163
+ child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
16164
+ return;
16165
+ }
16099
16166
  if (process.argv[2] === 'resume-waiting') {
16100
16167
  const state = isServerRunning();
16101
16168
  if (!state) { printNotRunningError(); process.exit(1); }
@@ -16258,7 +16325,7 @@ async function main() {
16258
16325
  log(`${na.length} session(s) need your attention:`, YELLOW);
16259
16326
  for (const it of na) {
16260
16327
  log(` ${YELLOW}⚠${RESET} ${it.name} ${DIM}${it.reason}${RESET}`, RESET);
16261
- log(` ${DIM}redo: ${CYAN}hop restore ${it.name}${RESET}`, RESET);
16328
+ log(` ${DIM}redo: ${CYAN}hop restore ${it.name}${RESET}${DIM}, or inside it: ${CYAN}hop recover${RESET}`, RESET);
16262
16329
  }
16263
16330
  process.exit(0);
16264
16331
  }
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+
4
+ // The directory the dialog is asking about, so an answer is only ever given
5
+ // for the workspace the session was launched in - never for wherever a
6
+ // shell happened to wander.
7
+ function trustDialogWorkspace(screenText) {
8
+ const m = /Accessing\s*workspace:?\s*(\/[^\s\u2502\u2500]+)/i.exec(String(screenText || ''));
9
+ return m ? m[1].replace(/\/+$/, '') : null;
10
+ }
11
+ /**
12
+ * What a trust dialog on screen is asking, and the exact keys that say yes.
13
+ *
14
+ * Two variants exist. The classic one names the workspace ("Accessing
15
+ * workspace: /path") and numbers its options ("1. Yes, proceed / 2. No,
16
+ * exit"): typing the number answers it. The other appears when the folder's
17
+ * .claude/settings.json pre-approves tool permissions: it names no path (the
18
+ * folder IS the launch directory), lists "No, exit" FIRST, and is an ARROW
19
+ * SELECTOR — a ❯ marks the highlighted option and Enter confirms it. Typing
20
+ * a digit does nothing there, and the Enter that follows confirms the
21
+ * default: "No, exit". That is exactly how motif's restore was declined
22
+ * three times over — "1\r" on 2026-09-04, "2\r" on 2026-09-10 — and the
23
+ * session came back as a shell each time. So the answer is read off the
24
+ * screen: the number when the options are numbered, otherwise enough arrow
25
+ * presses to move the highlight onto the yes option, then Enter.
26
+ */
27
+ function trustDialogAnswer(screenText, sessionCwd) {
28
+ const text = String(screenText || '');
29
+ const asked = trustDialogWorkspace(text);
30
+ const preapproval = /pre-?approves\s*\d+\s*tool\s*permissions|Only\s*proceed\s*if\s*you\s*trust\s*this\s*configuration/i.test(text);
31
+ const lines = text.split('\n');
32
+ const isYes = (l) => /Yes,?\s*(I\s*trust\s*this\s*folder|proceed)/i.test(l);
33
+ const isOption = (l) => isYes(l) || /No,?\s*exit/i.test(l);
34
+ const yesIdx = lines.findIndex(isYes);
35
+ let input = '1\r';
36
+ let how = 'option 1';
37
+ if (yesIdx >= 0) {
38
+ const numbered = /(?:^|\s)(\d+)\.\s*Yes/i.exec(lines[yesIdx]);
39
+ if (numbered) {
40
+ input = `${numbered[1]}\r`;
41
+ how = `option ${numbered[1]}`;
42
+ } else {
43
+ // Highlighted row → yes row, counting only option rows between.
44
+ const highlighted = lines.findIndex((l) => /[❯›>]\s*(Yes|No)/i.test(l));
45
+ const from = highlighted >= 0 ? highlighted : lines.findIndex(isOption);
46
+ const between = lines.slice(Math.min(from, yesIdx), Math.max(from, yesIdx)).filter(isOption).length;
47
+ const steps = from === yesIdx ? 0 : Math.max(0, between - (from < yesIdx ? 0 : 0));
48
+ const arrow = from < yesIdx ? '\x1b[B' : '\x1b[A';
49
+ input = arrow.repeat(from === yesIdx ? 0 : Math.max(1, steps)) + '\r';
50
+ how = from === yesIdx ? 'Enter on the highlighted yes' : `${from < yesIdx ? 'down' : 'up'} ×${Math.max(1, steps)} + Enter`;
51
+ }
52
+ }
53
+ const ours = asked ? samePath(asked, sessionCwd) : preapproval;
54
+ return { input, how, asked, preapproval, ours };
55
+ }
56
+ function samePath(a, b) {
57
+ const norm = (p) => {
58
+ let v = String(p || '').replace(/\/+$/, '');
59
+ try { v = fs.realpathSync(v); } catch (e) { /* keep as-is */ }
60
+ return v;
61
+ };
62
+ return !!a && !!b && norm(a) === norm(b);
63
+ }
64
+
65
+ module.exports = { trustDialogWorkspace, trustDialogAnswer, samePath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.45",
3
+ "version": "0.11.46",
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": {
@@ -0,0 +1,45 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { trustDialogAnswer } = require('../lib/trust-dialog');
4
+
5
+ const classic = `
6
+ Quick safety check
7
+ Accessing workspace: /Users/me/proj
8
+ Is this a project you created or one you trust?
9
+ ❯ 1. Yes, proceed
10
+ 2. No, exit
11
+ `;
12
+ const preapproval = `
13
+ ⚠ This folder pre-approves 15 tool permissions in .claude/settings.json:
14
+ mcp__x__read, mcp__y__write, and 7 more
15
+ These will apply without asking. Only proceed if you trust this configuration.
16
+ ❯ No, exit
17
+ Yes, I trust this folder
18
+ Enter to confirm · Esc to cancel
19
+ `;
20
+
21
+ test('classic numbered dialog: type the number of the yes option, only for the session\'s own workspace', () => {
22
+ const ours = trustDialogAnswer(classic, '/Users/me/proj');
23
+ assert.equal(ours.input, '1\r');
24
+ assert.equal(ours.asked, '/Users/me/proj');
25
+ assert.equal(ours.ours, true);
26
+ assert.equal(trustDialogAnswer(classic, '/Users/me/elsewhere').ours, false);
27
+ });
28
+
29
+ test('pre-approval dialog is an arrow selector with "No, exit" highlighted: Down then Enter (the motif regression)', () => {
30
+ const a = trustDialogAnswer(preapproval, '/Users/me');
31
+ assert.equal(a.preapproval, true);
32
+ assert.equal(a.input, '\x1b[B\r');
33
+ assert.equal(a.asked, null); // names no path: the folder is the launch directory
34
+ assert.equal(a.ours, true);
35
+ });
36
+
37
+ test('a selector already highlighting yes needs only Enter', () => {
38
+ const t = preapproval.replace('❯ No, exit', ' No, exit').replace(' Yes, I trust', '❯ Yes, I trust');
39
+ assert.equal(trustDialogAnswer(t, '/Users/me').input, '\r');
40
+ });
41
+
42
+ test('a numbered pre-approval layout takes its number', () => {
43
+ const t = preapproval.replace('❯ No, exit', ' 1. No, exit').replace(' Yes, I trust', '❯ 2. Yes, I trust');
44
+ assert.equal(trustDialogAnswer(t, '/Users/me').input, '2\r');
45
+ });