hop2 0.11.84 → 0.11.85
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/hay/apps/web/src/components/SessionSwitcher.tsx +4 -4
- package/hay/apps/web/src/components/ViewsPanel.tsx +4 -3
- package/hay/apps/web/src/styles.css +2 -0
- package/hop +86 -5
- package/package.json +1 -1
- package/scripts/codex-notify-hook.js +31 -2
- package/tests/codex-notify-hook.unit.test.cjs +37 -0
- package/tests/terminal-api.integration.test.cjs +74 -1
|
@@ -362,8 +362,8 @@ const PHONE_ICONS: Record<string, string> = {
|
|
|
362
362
|
moon: "M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z",
|
|
363
363
|
gear: "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"
|
|
364
364
|
};
|
|
365
|
-
const PhoneIcon = ({ name }: { name: string }) => (
|
|
366
|
-
<svg width=
|
|
365
|
+
const PhoneIcon = ({ name, size = 20 }: { name: string; size?: number }) => (
|
|
366
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
367
367
|
<path d={PHONE_ICONS[name] || ""} />
|
|
368
368
|
</svg>
|
|
369
369
|
);
|
|
@@ -3703,14 +3703,14 @@ export const SessionSwitcher = ({
|
|
|
3703
3703
|
{onOpenDesktop && (
|
|
3704
3704
|
<button type="button" className="switcher-action views-entry" aria-label="Remote desktop"
|
|
3705
3705
|
title="This machine's screen, through hop" onClick={() => onOpenDesktop()}>
|
|
3706
|
-
<span
|
|
3706
|
+
<span className="switcher-action-icon"><PhoneIcon name="display" size={15} /></span>
|
|
3707
3707
|
Desktop
|
|
3708
3708
|
</button>
|
|
3709
3709
|
)}
|
|
3710
3710
|
{onOpenCheckbacks && scheduledTotal > 0 && (
|
|
3711
3711
|
<button type="button" className="switcher-action views-entry" aria-label="Scheduled on every session"
|
|
3712
3712
|
title="Every check-back hop will type, across your sessions" onClick={() => onOpenCheckbacks("*")}>
|
|
3713
|
-
<span
|
|
3713
|
+
<span className="switcher-action-icon"><PhoneIcon name="clock" size={15} /></span>
|
|
3714
3714
|
Scheduled
|
|
3715
3715
|
<span className="views-entry-count">{scheduledTotal}</span>
|
|
3716
3716
|
</button>
|
|
@@ -481,9 +481,10 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
|
|
|
481
481
|
onMouseLeave={() => setArmedDelete((a) => (a === item.path ? null : a))}
|
|
482
482
|
>
|
|
483
483
|
{isImage(item.name)
|
|
484
|
-
//
|
|
485
|
-
// plots
|
|
486
|
-
|
|
484
|
+
// A small server-made thumbnail (the daemon falls back to the file
|
|
485
|
+
// itself): a list of plots scannable at a glance, without pulling
|
|
486
|
+
// every full image through the tunnel to draw it.
|
|
487
|
+
? <img className="views-thumb" src={item.path.replace(/\/inline$/, "/thumb")} loading="lazy" decoding="async" alt="" />
|
|
487
488
|
: <span className="views-kind" aria-hidden="true">{kindOf(item.name, item.kind)}</span>}
|
|
488
489
|
<span className="views-row-text">
|
|
489
490
|
<span className="views-title">{item.title || item.name}</span>
|
|
@@ -5824,3 +5824,5 @@ body.pane-dragging { user-select: none; }
|
|
|
5824
5824
|
color: var(--accent);
|
|
5825
5825
|
cursor: pointer;
|
|
5826
5826
|
}
|
|
5827
|
+
/* Line icons in the wall's header buttons sit on the text like its glyphs. */
|
|
5828
|
+
.switcher-action-icon { display: inline-flex; align-items: center; opacity: 0.85; }
|
package/hop
CHANGED
|
@@ -2746,6 +2746,60 @@ function saveHopConfig(config) {
|
|
|
2746
2746
|
writePrivateFile(HOP_CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
2747
2747
|
}
|
|
2748
2748
|
|
|
2749
|
+
// --- View thumbnails ---
|
|
2750
|
+
// Small JPEGs for the Views list, made once per file version with macOS's
|
|
2751
|
+
// sips (ImageMagick elsewhere, when installed) and kept under the hop home.
|
|
2752
|
+
// A few at a time: a list opening asks for a screenful at once. No tool, or
|
|
2753
|
+
// a failure → null, and the caller serves the file itself as before.
|
|
2754
|
+
const VIEW_THUMB_DIR = path.join(HOME_DIR, 'cache', 'view-thumbs');
|
|
2755
|
+
const VIEW_THUMB_PX = 160;
|
|
2756
|
+
const VIEW_THUMB_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.heic', '.tif', '.tiff', '.bmp']);
|
|
2757
|
+
const viewThumbQueue = [];
|
|
2758
|
+
const viewThumbWaiting = new Map();
|
|
2759
|
+
let viewThumbRunning = 0;
|
|
2760
|
+
let viewThumbTool;
|
|
2761
|
+
function findViewThumbTool() {
|
|
2762
|
+
if (viewThumbTool !== undefined) return viewThumbTool;
|
|
2763
|
+
const has = (bin) => { try { execFileSync('which', [bin], { stdio: 'ignore' }); return true; } catch (e) { return false; } };
|
|
2764
|
+
viewThumbTool = process.platform === 'darwin' && has('sips') ? 'sips' : has('magick') ? 'magick' : has('convert') ? 'convert' : null;
|
|
2765
|
+
return viewThumbTool;
|
|
2766
|
+
}
|
|
2767
|
+
function viewThumbnail(file, done) {
|
|
2768
|
+
const ext = path.extname(file).toLowerCase();
|
|
2769
|
+
const tool = VIEW_THUMB_EXTS.has(ext) ? findViewThumbTool() : null;
|
|
2770
|
+
if (!tool) { done(null); return; }
|
|
2771
|
+
let st;
|
|
2772
|
+
try { st = fs.statSync(file); } catch (e) { done(null); return; }
|
|
2773
|
+
const key = crypto.createHash('sha1').update(`${file}\0${st.size}\0${st.mtimeMs}`).digest('hex');
|
|
2774
|
+
const out = path.join(VIEW_THUMB_DIR, key + '.jpg');
|
|
2775
|
+
if (fs.existsSync(out)) { done(out); return; }
|
|
2776
|
+
if (viewThumbWaiting.has(out)) { viewThumbWaiting.get(out).push(done); return; }
|
|
2777
|
+
viewThumbWaiting.set(out, [done]);
|
|
2778
|
+
viewThumbQueue.push({ file, out, tool });
|
|
2779
|
+
pumpViewThumbs();
|
|
2780
|
+
}
|
|
2781
|
+
function pumpViewThumbs() {
|
|
2782
|
+
while (viewThumbRunning < 3 && viewThumbQueue.length) {
|
|
2783
|
+
const { file, out, tool } = viewThumbQueue.shift();
|
|
2784
|
+
viewThumbRunning++;
|
|
2785
|
+
try { fs.mkdirSync(VIEW_THUMB_DIR, { recursive: true }); } catch (e) { /* the write below fails */ }
|
|
2786
|
+
const tmp = `${out}.${process.pid}.tmp.jpg`;
|
|
2787
|
+
const args = tool === 'sips'
|
|
2788
|
+
? ['-Z', String(VIEW_THUMB_PX), '-s', 'format', 'jpeg', '-s', 'formatOptions', '70', file, '--out', tmp]
|
|
2789
|
+
: (tool === 'magick' ? ['convert'] : []).concat([`${file}[0]`, '-thumbnail', `${VIEW_THUMB_PX}x${VIEW_THUMB_PX}>`, '-quality', '70', tmp]);
|
|
2790
|
+
execFile(tool === 'magick' ? 'magick' : tool, args, { timeout: 15000 }, (err) => {
|
|
2791
|
+
viewThumbRunning--;
|
|
2792
|
+
let result = null;
|
|
2793
|
+
if (!err) { try { fs.renameSync(tmp, out); result = out; } catch (e) { /* none */ } }
|
|
2794
|
+
try { fs.unlinkSync(tmp); } catch (e) { /* renamed or never made */ }
|
|
2795
|
+
const waiters = viewThumbWaiting.get(out) || [];
|
|
2796
|
+
viewThumbWaiting.delete(out);
|
|
2797
|
+
for (const w of waiters) { try { w(result); } catch (e) { /* the response went away */ } }
|
|
2798
|
+
pumpViewThumbs();
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2749
2803
|
// --- Remote desktop ---
|
|
2750
2804
|
// hop carries a VNC server that is already on the machine (macOS Screen
|
|
2751
2805
|
// Sharing, or x11vnc/wayvnc on Linux) through its own authenticated link:
|
|
@@ -7731,6 +7785,11 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
7731
7785
|
if (refiled.length) log(`${dryRun ? 'Would re-file' : 'Re-filed'} ${refiled.length} session(s) into their folders from the identity snapshot.`, DIM);
|
|
7732
7786
|
const snapshot = await listAllSessionEntriesRuntimeFirst();
|
|
7733
7787
|
const live = new Set(snapshot.entries.filter((e) => e.live).map((e) => e.internalName));
|
|
7788
|
+
// The host cuts room ids to 32 characters, so a record filed under a
|
|
7789
|
+
// longer name (forks named after their source did this) belongs to the
|
|
7790
|
+
// room with the cut name; while that runs, the record is not a dead
|
|
7791
|
+
// session to bring back.
|
|
7792
|
+
const isLive = (name) => live.has(name) || (name.length > 32 && live.has(name.slice(0, 32)));
|
|
7734
7793
|
// Targeted redo (`hop restore <name>...`): the named sessions are planned
|
|
7735
7794
|
// even when live - a failed restore leaves a LIVE room (bare shell,
|
|
7736
7795
|
// parked dialog), which is exactly what a fleet restore skips, so the
|
|
@@ -7787,14 +7846,14 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
7787
7846
|
// angler-codex-claude-codex, 2026-10-12). Let the old ones go first.
|
|
7788
7847
|
if (closedAny) await new Promise((r) => setTimeout(r, 2000));
|
|
7789
7848
|
}
|
|
7790
|
-
const candidates = snapshot.entries.filter((e) => e && e.type === 'terminal' && !
|
|
7849
|
+
const candidates = snapshot.entries.filter((e) => e && e.type === 'terminal' && !isLive(e.internalName));
|
|
7791
7850
|
// Primary source: the SessionStart-hook records. Add any recorded session that
|
|
7792
7851
|
// isn't already live or covered by the store, so restore works even though the
|
|
7793
7852
|
// workspace store is empty. restorePlanForSession() reads each record and turns
|
|
7794
7853
|
// it into `claude --resume <sessionId>`.
|
|
7795
7854
|
const known = new Set(candidates.map((e) => e.internalName));
|
|
7796
7855
|
for (const rec of listClaudeSessionRecords()) {
|
|
7797
|
-
if (
|
|
7856
|
+
if (isLive(rec.internalName) || known.has(rec.internalName)) continue;
|
|
7798
7857
|
known.add(rec.internalName);
|
|
7799
7858
|
candidates.push({
|
|
7800
7859
|
internalName: rec.internalName,
|
|
@@ -7812,7 +7871,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
7812
7871
|
// record, so after a HARD crash (no graceful-shutdown buffers) the meta's cwd
|
|
7813
7872
|
// is its only trace — reopen a fresh shell there.
|
|
7814
7873
|
for (const meta of listClaudeSessionMetas()) {
|
|
7815
|
-
if (
|
|
7874
|
+
if (isLive(meta.internalName) || known.has(meta.internalName)) continue;
|
|
7816
7875
|
if (!meta.cwd || typeof meta.cwd !== 'string') continue;
|
|
7817
7876
|
known.add(meta.internalName);
|
|
7818
7877
|
candidates.push({
|
|
@@ -12616,7 +12675,7 @@ happens until someone clicks it.
|
|
|
12616
12675
|
// file name. The legacy /assets/view/<s>/<f> form redirects here, so
|
|
12617
12676
|
// an edge-cached response is at worst a redirect, never the bytes.
|
|
12618
12677
|
const viewMatch = (req.method === 'GET' || req.method === 'HEAD')
|
|
12619
|
-
&& parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline|\/download)?$/);
|
|
12678
|
+
&& parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline|\/download|\/thumb)?$/);
|
|
12620
12679
|
if (viewMatch) {
|
|
12621
12680
|
// /download: the stored file itself, as an attachment — the
|
|
12622
12681
|
// markdown an agent wrote rather than the page it renders into,
|
|
@@ -12632,6 +12691,23 @@ happens until someone clicks it.
|
|
|
12632
12691
|
return;
|
|
12633
12692
|
}
|
|
12634
12693
|
const viewExt = path.extname(target).toLowerCase();
|
|
12694
|
+
// /thumb: a small JPEG for the Views list. The list used to load
|
|
12695
|
+
// every image whole as its own thumbnail — 39 MB through the
|
|
12696
|
+
// tunnel to draw one screen of rows, with the view you picked
|
|
12697
|
+
// queued behind it.
|
|
12698
|
+
if (viewMatch[3] === '/thumb') {
|
|
12699
|
+
viewThumbnail(target, (thumb) => {
|
|
12700
|
+
if (!thumb) {
|
|
12701
|
+
res.writeHead(302, { Location: `/view/${viewMatch[1]}/${viewMatch[2]}/inline` });
|
|
12702
|
+
res.end();
|
|
12703
|
+
return;
|
|
12704
|
+
}
|
|
12705
|
+
res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': fs.statSync(thumb).size, 'Cache-Control': 'private, max-age=86400' });
|
|
12706
|
+
if (req.method === 'HEAD') { res.end(); return; }
|
|
12707
|
+
fs.createReadStream(thumb).pipe(res);
|
|
12708
|
+
});
|
|
12709
|
+
return;
|
|
12710
|
+
}
|
|
12635
12711
|
// ?download=1 — the FILE, not the page: the bytes the agent
|
|
12636
12712
|
// published, under their real name, as an attachment. Rendered
|
|
12637
12713
|
// kinds (md/csv/json) come back raw here, not as the HTML the
|
|
@@ -13317,7 +13393,12 @@ happens until someone clicks it.
|
|
|
13317
13393
|
forkName = forkBase + suffix + n;
|
|
13318
13394
|
n += 1;
|
|
13319
13395
|
}
|
|
13320
|
-
|
|
13396
|
+
// An opaque id like any new session's, not the name: the
|
|
13397
|
+
// host cuts room ids to 32 characters, so a long name ran
|
|
13398
|
+
// as a shorter room than its records were filed under,
|
|
13399
|
+
// and a later `hop restore` took the record for a dead
|
|
13400
|
+
// session and listed it as restored.
|
|
13401
|
+
const forkInternal = mintSessionInternalName();
|
|
13321
13402
|
|
|
13322
13403
|
let command;
|
|
13323
13404
|
let kind = 'shell';
|
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
const fs = require("node:fs");
|
|
23
23
|
const os = require("node:os");
|
|
24
24
|
const path = require("node:path");
|
|
25
|
-
const { spawn } = require("node:child_process");
|
|
25
|
+
const { spawn, execFileSync } = require("node:child_process");
|
|
26
26
|
|
|
27
27
|
function turnsDir() {
|
|
28
28
|
const home = process.env.HOP_HOME || path.join(os.homedir(), ".hop2");
|
|
@@ -63,6 +63,32 @@ function resolveHopSession(dir, rootId, envSession) {
|
|
|
63
63
|
return owners.length === 1 ? owners[0] : envSession;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Codex's shared app-server daemon runs the notify of every thread it
|
|
67
|
+
// serves with ITS OWN environment — the session that happened to start it.
|
|
68
|
+
// One started inside one session filed another session's turns as its own,
|
|
69
|
+
// so that record named the other conversation and `hop recover` there
|
|
70
|
+
// brought the wrong one back. Under the daemon
|
|
71
|
+
// HOP_SESSION says nothing about the thread. Codex runs this hook through
|
|
72
|
+
// whatever notify chain is configured (Computer Use wraps it), so walk up
|
|
73
|
+
// to the first codex process and look at how it runs.
|
|
74
|
+
function underSharedCodexDaemon() {
|
|
75
|
+
let pid = process.ppid;
|
|
76
|
+
for (let i = 0; i < 8 && pid > 1; i++) {
|
|
77
|
+
let row = "";
|
|
78
|
+
let args = "";
|
|
79
|
+
try {
|
|
80
|
+
row = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 }).trim();
|
|
81
|
+
args = execFileSync("ps", ["-o", "args=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 }).trim();
|
|
82
|
+
} catch { return false; }
|
|
83
|
+
const m = /^(\d+)\s+(.*)$/.exec(row);
|
|
84
|
+
if (!m) return false;
|
|
85
|
+
// The executable itself, not any command line that mentions it.
|
|
86
|
+
if (path.basename(m[2]) === "codex") return /\sapp-server(\s|$)/.test(args);
|
|
87
|
+
pid = Number(m[1]);
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
66
92
|
// A sub-agent's rollout names its parent in its first line (session_meta →
|
|
67
93
|
// parent_thread_id). Walk up to the root, reading only that line of each
|
|
68
94
|
// file — a long thread's rollout runs to hundreds of MB. Any failure leaves
|
|
@@ -114,7 +140,10 @@ function bumpTurn(payload) {
|
|
|
114
140
|
: (payload && typeof payload.thread_id === "string") ? payload.thread_id
|
|
115
141
|
: (payload && typeof payload.session_id === "string") ? payload.session_id : null;
|
|
116
142
|
let rootId = rootThreadId(rawThread);
|
|
117
|
-
|
|
143
|
+
// Under the shared daemon the environment is another session's: the
|
|
144
|
+
// turn goes only to a record that already names this thread, else nowhere.
|
|
145
|
+
const hopSession = underSharedCodexDaemon() ? resolveHopSession(dir, rootId, null) : resolveHopSession(dir, rootId, envSession);
|
|
146
|
+
if (!hopSession) return;
|
|
118
147
|
const file = path.join(dir, `${hopSession}.turn`);
|
|
119
148
|
let count = 0;
|
|
120
149
|
let prev = null;
|
|
@@ -61,3 +61,40 @@ test('other event types count nothing', () => {
|
|
|
61
61
|
run(home, 'alpha', { type: 'something-else', 'thread-id': 'thread-A' });
|
|
62
62
|
assert.equal(turn(home, 'alpha'), null);
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
// Codex's shared app-server daemon runs every thread's notify with the
|
|
66
|
+
// environment of whichever session started it. A parent whose command line
|
|
67
|
+
// says app-server stands in for it here.
|
|
68
|
+
// The daemon is an executable named codex running `app-server`: a copy of
|
|
69
|
+
// node under that name, running the hook, stands in for it.
|
|
70
|
+
const runAsDaemon = (home, session, json) => {
|
|
71
|
+
const bin = path.join(home, 'bin'); fs.mkdirSync(bin, { recursive: true });
|
|
72
|
+
const codex = path.join(bin, 'codex');
|
|
73
|
+
if (!fs.existsSync(codex)) fs.symlinkSync(process.execPath, codex);
|
|
74
|
+
const script = path.join(bin, 'daemon.js');
|
|
75
|
+
fs.writeFileSync(script, 'require("child_process").execFileSync(process.execPath, process.argv.slice(3), { stdio: "inherit" })');
|
|
76
|
+
return require('node:child_process').spawnSync(codex, [script, 'app-server', HOOK, json], {
|
|
77
|
+
env: { ...process.env, HOP_HOME: home, HOP_SESSION: session, CODEX_HOME: path.join(home, 'codex') }
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
test("under Codex's shared daemon a turn is not filed against the daemon's session", () => {
|
|
82
|
+
const home = freshHome();
|
|
83
|
+
saveRollout(home, 'thread-starter'); saveRollout(home, 'thread-other');
|
|
84
|
+
fs.writeFileSync(path.join(home, 'claude-sessions', 'starter.turn'), JSON.stringify({ sessionId: 'thread-starter', count: 4, at: '2026-10-14T00:00:00.000Z', agent: 'codex' }));
|
|
85
|
+
fs.writeFileSync(path.join(home, 'claude-sessions', 'other.turn'), JSON.stringify({ sessionId: 'thread-other', count: 7, at: '2026-10-14T00:00:00.000Z', agent: 'codex' }));
|
|
86
|
+
// Another session's thread finishes a turn inside the daemon the starter session started.
|
|
87
|
+
const daemon = runAsDaemon(home, 'starter', JSON.stringify({ type: 'agent-turn-complete', 'thread-id': 'thread-other', 'turn-id': 't1' }));
|
|
88
|
+
assert.equal(daemon.status, 0, String(daemon.stderr));
|
|
89
|
+
assert.equal(turn(home, 'starter').sessionId, 'thread-starter', "the starter's record keeps its own thread");
|
|
90
|
+
assert.equal(turn(home, 'starter').count, 4, 'and is not counted');
|
|
91
|
+
assert.equal(turn(home, 'other').count, 8, 'the turn goes to the record that names the thread');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('under the shared daemon a thread no record names is filed nowhere', () => {
|
|
95
|
+
const home = freshHome();
|
|
96
|
+
saveRollout(home, 'thread-new');
|
|
97
|
+
const daemon = runAsDaemon(home, 'starter', JSON.stringify({ type: 'agent-turn-complete', 'thread-id': 'thread-new', 'turn-id': 't1' }));
|
|
98
|
+
assert.equal(daemon.status, 0, String(daemon.stderr));
|
|
99
|
+
assert.equal(turn(home, 'starter'), null, 'no record is invented for the daemon session');
|
|
100
|
+
});
|
|
@@ -1055,7 +1055,7 @@ test('a session whose display name has spaces can be renamed and forked', async
|
|
|
1055
1055
|
internalName: internal
|
|
1056
1056
|
});
|
|
1057
1057
|
assert.equal(fork.status, 200, JSON.stringify(fork.data));
|
|
1058
|
-
assert.match(fork.data.internalName, /^[
|
|
1058
|
+
assert.match(fork.data.internalName, /^s_[0-9a-f]{10}$/, 'a fork gets an opaque id, not its (possibly long) name');
|
|
1059
1059
|
|
|
1060
1060
|
const rename = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename', {
|
|
1061
1061
|
oldName: 'Find grant opportunities', newName: 'grant-search', internalName: internal
|
|
@@ -1069,6 +1069,37 @@ test('a session whose display name has spaces can be renamed and forked', async
|
|
|
1069
1069
|
});
|
|
1070
1070
|
|
|
1071
1071
|
|
|
1072
|
+
// The host cuts room ids to 32 characters. A record filed under a longer
|
|
1073
|
+
// name belongs to the room with the cut name, and while that room runs,
|
|
1074
|
+
// restore must not plan the record as a dead session.
|
|
1075
|
+
test('restore does not take a long-named record for dead while its cut room runs', async () => {
|
|
1076
|
+
const dir = path.join(hopHome, 'claude-sessions');
|
|
1077
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1078
|
+
const cut = 'longnamerecordcheck-abcdefghijkl'; // 32 characters
|
|
1079
|
+
const long = cut + '-continued';
|
|
1080
|
+
assert.equal(cut.length, 32);
|
|
1081
|
+
await fs.writeFile(path.join(dir, `${cut}.meta`), JSON.stringify({ internalName: cut, cwd: tempDir }), { mode: 0o600 });
|
|
1082
|
+
const up = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/restore', { only: [cut] });
|
|
1083
|
+
assert.equal(up.status, 200, JSON.stringify(up.data));
|
|
1084
|
+
let running = false;
|
|
1085
|
+
for (let i = 0; i < 40 && !running; i++) {
|
|
1086
|
+
const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
1087
|
+
running = !!list.data.sessions.find((x) => x.internalName === cut && x.live !== false);
|
|
1088
|
+
if (!running) await delay(150);
|
|
1089
|
+
}
|
|
1090
|
+
assert.ok(running, 'the 32-character room is up');
|
|
1091
|
+
|
|
1092
|
+
await fs.writeFile(path.join(dir, `${long}.meta`), JSON.stringify({ internalName: long, displayName: long, cwd: tempDir }), { mode: 0o600 });
|
|
1093
|
+
const plan = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/restore', { dryRun: true });
|
|
1094
|
+
assert.equal(plan.status, 200, JSON.stringify(plan.data));
|
|
1095
|
+
const names = (plan.data.restored || []).map((x) => x.name);
|
|
1096
|
+
assert.ok(!names.includes(long), `planned the live session again: ${names.join(', ')}`);
|
|
1097
|
+
|
|
1098
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: cut, internalName: cut });
|
|
1099
|
+
await fs.rm(path.join(dir, `${long}.meta`), { force: true });
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
|
|
1072
1103
|
// A session continued from the Codex app has no turn record until its first
|
|
1073
1104
|
// turn finishes in hop — and until then Continue-in-Claude refused it as a
|
|
1074
1105
|
// plain shell. Its record names the app thread, and that thread's rollout
|
|
@@ -1526,3 +1557,45 @@ test('the desktop pipe is off by default and, once on, carries bytes both ways',
|
|
|
1526
1557
|
vnc.close();
|
|
1527
1558
|
}
|
|
1528
1559
|
});
|
|
1560
|
+
|
|
1561
|
+
test('a view image has a small thumbnail; other kinds fall back to the file', async () => {
|
|
1562
|
+
const dir = path.join(hopHome, 'views', 'thumbprobe');
|
|
1563
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1564
|
+
// A 400x300 PNG, written by hand: one solid-colour row repeated.
|
|
1565
|
+
const zlib = require('node:zlib');
|
|
1566
|
+
const w = 400, h = 300;
|
|
1567
|
+
const crc = (buf) => { let c = ~0; for (const b of buf) { c ^= b; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); } return ~c >>> 0; };
|
|
1568
|
+
const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([len, td, c]); };
|
|
1569
|
+
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
|
1570
|
+
const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(w * 3, 0x80)]);
|
|
1571
|
+
const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr),
|
|
1572
|
+
chunk('IDAT', zlib.deflateSync(Buffer.concat(Array(h).fill(row)))), chunk('IEND', Buffer.alloc(0))]);
|
|
1573
|
+
await fs.writeFile(path.join(dir, 'plot.png'), png);
|
|
1574
|
+
await fs.writeFile(path.join(dir, 'notes.pdf'), Buffer.alloc(64, 1));
|
|
1575
|
+
const get = (reqPath) => new Promise((resolve, reject) => {
|
|
1576
|
+
const req = http.request({ hostname: '127.0.0.1', port: state.port, path: reqPath, method: 'GET',
|
|
1577
|
+
headers: { Authorization: `Bearer ${state.sessionSecret}` } }, (res) => {
|
|
1578
|
+
const parts = []; res.on('data', (d) => parts.push(d));
|
|
1579
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(parts) }));
|
|
1580
|
+
});
|
|
1581
|
+
req.on('error', reject); req.end();
|
|
1582
|
+
});
|
|
1583
|
+
try {
|
|
1584
|
+
const thumb = await get('/view/thumbprobe/plot.png/thumb');
|
|
1585
|
+
if (thumb.status === 200) {
|
|
1586
|
+
assert.equal(thumb.headers['content-type'], 'image/jpeg');
|
|
1587
|
+
assert.ok(thumb.body.length < png.length, 'smaller than the image itself');
|
|
1588
|
+
const again = await get('/view/thumbprobe/plot.png/thumb');
|
|
1589
|
+
assert.deepEqual(again.body, thumb.body, 'served from the cache the second time');
|
|
1590
|
+
} else {
|
|
1591
|
+
// No image tool on this machine: the file itself, as before.
|
|
1592
|
+
assert.equal(thumb.status, 302);
|
|
1593
|
+
assert.equal(thumb.headers.location, '/view/thumbprobe/plot.png/inline');
|
|
1594
|
+
}
|
|
1595
|
+
const pdf = await get('/view/thumbprobe/notes.pdf/thumb');
|
|
1596
|
+
assert.equal(pdf.status, 302);
|
|
1597
|
+
assert.equal((await get('/view/thumbprobe/missing.png/thumb')).status, 404);
|
|
1598
|
+
} finally {
|
|
1599
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
1600
|
+
}
|
|
1601
|
+
});
|