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
|
-
//
|
|
2654
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
6568
|
-
if (
|
|
6569
|
-
try { sendTerminalInput(job.entry,
|
|
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
|
@@ -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
|
+
});
|