hop2 0.11.45 → 0.11.47

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.
@@ -123,6 +123,27 @@ const formatBytes = (n?: number) => {
123
123
  const hrefFor = (item: ViewItem) =>
124
124
  item.kind === "server" && item.target ? item.target : item.path;
125
125
 
126
+ // The stored file itself, as an attachment: the daemon's /download mode
127
+ // serves the markdown an agent wrote rather than the page it renders into.
128
+ // Only files — a server is live, there is nothing to save.
129
+ const downloadHrefFor = (item: ViewItem) =>
130
+ item.kind === "server" ? null : item.path.replace(/\/inline$/, "") + "/download";
131
+
132
+ // A hidden same-origin anchor with `download`: the row is already an <a>
133
+ // (anchors cannot nest), and window.open would show the file, not save it.
134
+ const triggerDownload = (item: ViewItem) => {
135
+ const href = downloadHrefFor(item);
136
+ if (!href) return;
137
+ const a = document.createElement("a");
138
+ a.href = href;
139
+ a.download = item.name;
140
+ a.rel = "noopener";
141
+ a.style.display = "none";
142
+ document.body.appendChild(a);
143
+ a.click();
144
+ a.remove();
145
+ };
146
+
126
147
  const extOf = (name: string) => (name.split(".").pop() || "").toLowerCase();
127
148
  const isVideo = (name: string) => ["mp4", "mov", "webm", "m4v", "ogv"].includes(extOf(name));
128
149
  const isAudio = (name: string) => ["mp3", "m4a", "wav", "flac", "aac", "ogg", "oga", "opus"].includes(extOf(name));
@@ -427,10 +448,25 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
427
448
  <button type="button" className="views-act"
428
449
  title="Open in a new tab" aria-label="Open in a new tab"
429
450
  onClick={() => window.open(hrefFor(item), "_blank", "noopener")}>↗</button>
451
+ {!isServer && (
452
+ <button type="button" className="views-act"
453
+ title="Download" aria-label={`Download ${item.name}`}
454
+ onClick={() => triggerDownload(item)}>⤓</button>
455
+ )}
430
456
  <button type="button" className="views-act"
431
457
  title={copied === item.path ? "Copied" : "Copy link"}
432
458
  aria-label="Copy link"
433
459
  onClick={() => copyLink(item)}>{copied === item.path ? "✓" : "⧉"}</button>
460
+ {!isServer && (
461
+ /* The FILE, not the page: ?download=1 serves the published bytes as
462
+ an attachment under their real name (md/csv/json raw, not the
463
+ rendered view). A window.open, not an anchor: anchors can't nest
464
+ in the row's own link, and the attachment header keeps the tab
465
+ from navigating. */
466
+ <button type="button" className="views-act"
467
+ title="Download" aria-label="Download"
468
+ onClick={() => window.open(item.path + "?download=1", "_blank", "noopener")}>⤓</button>
469
+ )}
434
470
  {!isServer && (
435
471
  <button type="button"
436
472
  className={"views-act danger" + (armedDelete === item.path ? " armed" : "")}
@@ -484,6 +520,11 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
484
520
  {preview.title || preview.name}
485
521
  </span>
486
522
  <span className="views-head-spacer" />
523
+ {preview.kind !== "server" && (
524
+ <button type="button" className="views-act"
525
+ title="Download" aria-label={`Download ${preview.name}`}
526
+ onClick={() => triggerDownload(preview)}>⤓</button>
527
+ )}
487
528
  <button type="button" className="views-act"
488
529
  title="Open in a new tab" aria-label="Open preview in a new tab"
489
530
  onClick={() => window.open(hrefFor(preview), "_blank", "noopener")}>↗</button>
@@ -28,6 +28,31 @@ beforeEach(() => {
28
28
  afterEach(() => { cleanup(); vi.clearAllMocks(); vi.unstubAllGlobals(); });
29
29
 
30
30
  describe("ViewsPanel", () => {
31
+ it("Download saves the stored file itself through a same-origin anchor, in the list and in the preview", async () => {
32
+ // The row is an <a>, so the button clicks a hidden anchor of its own;
33
+ // capture what that anchor asked the browser to do.
34
+ const clicked: Array<{ href: string; download: string }> = [];
35
+ const origClick = HTMLAnchorElement.prototype.click;
36
+ HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) {
37
+ clicked.push({ href: this.getAttribute("href") || "", download: this.download });
38
+ };
39
+ try {
40
+ render(<ViewsPanel onClose={() => {}} />);
41
+ await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
42
+ fireEvent.click(screen.getByRole("button", { name: "Download agent-result.md" }));
43
+ expect(clicked).toEqual([{ href: "/view/Orion/agent-result.md/download", download: "agent-result.md" }]);
44
+ // Open the preview: its header offers the same download.
45
+ fireEvent.click(screen.getByText("Views end-to-end").closest("a") as HTMLAnchorElement);
46
+ const inPreview = screen.getAllByRole("button", { name: "Download agent-result.md" });
47
+ expect(inPreview.length).toBeGreaterThanOrEqual(2);
48
+ fireEvent.click(inPreview[inPreview.length - 1]);
49
+ expect(clicked.length).toBe(2);
50
+ expect(clicked[1].href).toBe("/view/Orion/agent-result.md/download");
51
+ } finally {
52
+ HTMLAnchorElement.prototype.click = origClick;
53
+ }
54
+ });
55
+
31
56
  it("a plain click previews IN PLACE; the row stays a real link for modified clicks", async () => {
32
57
  render(<ViewsPanel onClose={() => {}} />);
33
58
  await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
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;
@@ -10782,8 +10793,12 @@ happens until someone clicks it.
10782
10793
  // file name. The legacy /assets/view/<s>/<f> form redirects here, so
10783
10794
  // an edge-cached response is at worst a redirect, never the bytes.
10784
10795
  const viewMatch = (req.method === 'GET' || req.method === 'HEAD')
10785
- && parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline)?$/);
10796
+ && parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline|\/download)?$/);
10786
10797
  if (viewMatch) {
10798
+ // /download: the stored file itself, as an attachment — the
10799
+ // markdown an agent wrote rather than the page it renders into,
10800
+ // the PDF rather than the viewer. Same bytes `hop view` copied in.
10801
+ const wantDownload = viewMatch[3] === '/download';
10787
10802
  const viewRoot = path.resolve(VIEWS_DIR);
10788
10803
  const target = path.resolve(path.join(
10789
10804
  viewRoot, decodeURIComponent(viewMatch[1]), decodeURIComponent(viewMatch[2])));
@@ -10794,11 +10809,31 @@ happens until someone clicks it.
10794
10809
  return;
10795
10810
  }
10796
10811
  const viewExt = path.extname(target).toLowerCase();
10812
+ // ?download=1 — the FILE, not the page: the bytes the agent
10813
+ // published, under their real name, as an attachment. Rendered
10814
+ // kinds (md/csv/json) come back raw here, not as the HTML the
10815
+ // viewer shows (Jian: "I should be able to download a hop view
10816
+ // item from the UI, right?").
10817
+ if (parsedUrl.query.download === '1') {
10818
+ const rawTypes = { '.md': 'text/markdown; charset=utf-8', '.markdown': 'text/markdown; charset=utf-8',
10819
+ '.csv': 'text/csv; charset=utf-8', '.tsv': 'text/tab-separated-values; charset=utf-8',
10820
+ '.json': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8' };
10821
+ const dlName = path.basename(target).replace(/["\r\n]/g, '_');
10822
+ res.writeHead(200, {
10823
+ 'Content-Type': rawTypes[viewExt] || STATIC_CONTENT_TYPES[viewExt] || 'application/octet-stream',
10824
+ 'Content-Length': fs.statSync(target).size,
10825
+ 'Content-Disposition': `attachment; filename="${dlName}"; filename*=UTF-8''${encodeURIComponent(dlName)}`,
10826
+ 'Cache-Control': 'no-store'
10827
+ });
10828
+ if (req.method === 'HEAD') { res.end(); return; }
10829
+ fs.createReadStream(target).pipe(res);
10830
+ return;
10831
+ }
10797
10832
  // Markdown is rendered, not dumped: an agent's write-up is a
10798
10833
  // RESULT, and handing over a wall of raw asterisks is the "just
10799
10834
  // text" outcome this feature exists to avoid. The stored file
10800
10835
  // stays the markdown the agent wrote.
10801
- if (VIEW_RENDERED.has(viewExt)) {
10836
+ if (VIEW_RENDERED.has(viewExt) && !wantDownload) {
10802
10837
  const sess = decodeURIComponent(viewMatch[1]);
10803
10838
  const file = decodeURIComponent(viewMatch[2]);
10804
10839
  const caption = readViewMeta(sess)[file]?.title || file;
@@ -10856,7 +10891,15 @@ happens until someone clicks it.
10856
10891
  fs.createReadStream(target, { start, end }).pipe(res);
10857
10892
  return;
10858
10893
  }
10894
+ // RFC 6266: an ASCII fallback plus the UTF-8 form for names with
10895
+ // anything else in them.
10896
+ const downloadName = path.basename(target);
10897
+ const asciiName = downloadName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
10898
+ const disposition = wantDownload
10899
+ ? { 'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(downloadName)}` }
10900
+ : {};
10859
10901
  res.writeHead(200, {
10902
+ ...disposition,
10860
10903
  'Content-Type': viewType,
10861
10904
  'Content-Length': viewSize,
10862
10905
  'Accept-Ranges': 'bytes'
@@ -15362,7 +15405,7 @@ async function attachToAllSessions(existingTunnel) {
15362
15405
  const KNOWN_COMMANDS = [
15363
15406
  'client', 'start', 'attach', 'local', 'stop', 'status', 'doctor', 'logs',
15364
15407
  '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',
15408
+ '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
15409
  'mail', 'registration', 'canonical'
15367
15410
  ];
15368
15411
 
@@ -15485,6 +15528,7 @@ const HELP_SECTIONS = [
15485
15528
  ] },
15486
15529
  { title: 'Restore', lines: [
15487
15530
  `hop restore [--dry-run] [--agents] Recreate your saved sessions, resuming claude conversations (--agents includes agent-created ones)`,
15531
+ `hop recover [<session>] Bring back the agent that last ran here: inside a session, relaunch its conversation in this terminal`,
15488
15532
  `hop restore <name>... Redo just those sessions — even live ones, if they hold only an idle shell or a startup dialog`,
15489
15533
  `hop restore --health Show sessions the reconciler could not bring back on its own, and why`,
15490
15534
  `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 +16140,61 @@ async function main() {
16096
16140
  // Handle `hop resume-waiting` — answer claude's resume question for any
16097
16141
  // session currently parked on it (sessions restored before this existed,
16098
16142
  // or restored with resume_mode=ask and since reconsidered).
16143
+ // hop recover [<session>] — bring back the agent that last ran in a
16144
+ // session. Inside a session with no argument, it relaunches that
16145
+ // session's own conversation IN THIS TERMINAL (the same flag-preserving
16146
+ // command `hop restore` would use), so a claude that exited — crash,
16147
+ // /exit, a wrong answer to a dialog — is one command away, and any
16148
+ // dialog it shows is answered by the human sitting there. Named from
16149
+ // anywhere, it is `hop restore <name>`.
16150
+ if (process.argv[2] === 'recover') {
16151
+ const args = process.argv.slice(3);
16152
+ const dryRun = args.includes('--dry-run') || args.includes('-n');
16153
+ const named = args.find((a) => !a.startsWith('-')) || null;
16154
+ const here = process.env.HOP_SESSION || null;
16155
+ if (!named && !here) {
16156
+ log(`Run ${CYAN}hop recover${RESET} inside a hop session, or name one: ${CYAN}hop recover <session>${RESET}`, YELLOW);
16157
+ process.exit(1);
16158
+ }
16159
+ const target = named ? (resolveSessionInternalName(named, named) || named) : here;
16160
+ if (target !== here) {
16161
+ // Not this terminal: the daemon relaunches it in its own room.
16162
+ const child = spawn(process.execPath, [__filename, 'restore', ...(dryRun ? ['--dry-run'] : []), named], { stdio: 'inherit' });
16163
+ child.on('exit', (code) => process.exit(code ?? 1));
16164
+ return;
16165
+ }
16166
+ const display = getSessionDisplayName(target) || target;
16167
+ const meta = readClaudeSessionMeta(target);
16168
+ const cfg = getSessionConfig(target);
16169
+ const cwd = meta?.cwd || cfg?.cwd || process.cwd();
16170
+ const rec = readClaudeSessionRecord(target);
16171
+ if (rec?.sessionId && claudeProcessRunning(rec.sessionId)) {
16172
+ log(`${display}: its claude conversation ${String(rec.sessionId).slice(0, 8)}… is already running in this session.`, YELLOW);
16173
+ process.exit(1);
16174
+ }
16175
+ const plan = restorePlanForSession({ internalName: target, cwd, startup: cfg?.startup, autoStart: cfg?.autoStart });
16176
+ let command = plan?.command || null;
16177
+ let kind = command ? 'claude' : null;
16178
+ if (!command) {
16179
+ // No claude record: a codex conversation is found by its rollout.
16180
+ const rollout = resolveCodexTranscriptByCwd(cwd);
16181
+ const rolloutId = rollout ? await trajectoryExtract.codexRolloutId(rollout) : null;
16182
+ if (rolloutId) {
16183
+ command = `codex resume '${rolloutId.replace(/'/g, `'\\''`)}'`;
16184
+ kind = 'codex';
16185
+ }
16186
+ }
16187
+ if (!command) {
16188
+ log(`${display}: nothing to recover — no claude or codex conversation is recorded for this session${plan?.warning ? ` (${plan.warning})` : ''}.`, YELLOW);
16189
+ process.exit(1);
16190
+ }
16191
+ log(`${dryRun ? 'Would recover' : 'Recovering'} ${BOLD}${display}${RESET} (${kind}) in this terminal:`, RESET);
16192
+ log(` ${DIM}${command}${RESET}`, RESET);
16193
+ if (dryRun) process.exit(0);
16194
+ const child = spawn('/bin/zsh', ['-lc', command], { stdio: 'inherit', cwd: plan?.cwd || cwd, env: process.env });
16195
+ child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
16196
+ return;
16197
+ }
16099
16198
  if (process.argv[2] === 'resume-waiting') {
16100
16199
  const state = isServerRunning();
16101
16200
  if (!state) { printNotRunningError(); process.exit(1); }
@@ -16258,7 +16357,7 @@ async function main() {
16258
16357
  log(`${na.length} session(s) need your attention:`, YELLOW);
16259
16358
  for (const it of na) {
16260
16359
  log(` ${YELLOW}⚠${RESET} ${it.name} ${DIM}${it.reason}${RESET}`, RESET);
16261
- log(` ${DIM}redo: ${CYAN}hop restore ${it.name}${RESET}`, RESET);
16360
+ log(` ${DIM}redo: ${CYAN}hop restore ${it.name}${RESET}${DIM}, or inside it: ${CYAN}hop recover${RESET}`, RESET);
16262
16361
  }
16263
16362
  process.exit(0);
16264
16363
  }
@@ -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.47",
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
+ });