hop2 0.11.2 → 0.11.4

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/hopa CHANGED
@@ -286,7 +286,12 @@ async function resolveTerminalRef(server, ref) {
286
286
  const parsed = JSON.parse((listed.content || []).map((c) => c.text || '').join(''));
287
287
  terminals = Array.isArray(parsed.terminals) ? parsed.terminals : [];
288
288
  } catch { return ref; }
289
- const matches = terminals.filter((t) => t && (t.sessionName === ref || t.displayName === ref || t.id === ref));
289
+ const exact = terminals.filter((t) => t && (t.sessionName === ref || t.displayName === ref || t.id === ref));
290
+ // Name refs fold case (unique match only) — same rule as the daemon's lookup.
291
+ const refLower = ref.toLowerCase();
292
+ const foldsTo = (v) => typeof v === 'string' && v.toLowerCase() === refLower;
293
+ const matches = exact.length ? exact
294
+ : terminals.filter((t) => t && (foldsTo(t.sessionName) || foldsTo(t.displayName) || foldsTo(t.id)));
290
295
  if (matches.length === 1) return matches[0].id;
291
296
  if (matches.length > 1) {
292
297
  fail(`"${ref}" matches ${matches.length} terminals: ${matches.map((t) => t.id).join(', ')}`, 2);
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ function emptyFailureState() {
4
+ return { pid: 0, count: 0, firstAt: 0 };
5
+ }
6
+
7
+ function createFailureTracker({ requiredFailures, windowMs, now = Date.now }) {
8
+ if (!Number.isInteger(requiredFailures) || requiredFailures < 1) {
9
+ throw new TypeError('requiredFailures must be a positive integer');
10
+ }
11
+ if (!Number.isFinite(windowMs) || windowMs < 0) {
12
+ throw new TypeError('windowMs must be a non-negative number');
13
+ }
14
+ if (typeof now !== 'function') {
15
+ throw new TypeError('now must be a function');
16
+ }
17
+
18
+ let state = emptyFailureState();
19
+
20
+ return {
21
+ recordFailure(pid) {
22
+ if (!Number.isInteger(pid) || pid <= 0) {
23
+ throw new TypeError('pid must be a positive integer');
24
+ }
25
+ const at = now();
26
+ if (state.pid !== pid) {
27
+ state = { pid, count: 0, firstAt: at };
28
+ }
29
+ state.count += 1;
30
+ const failingForMs = Math.max(0, at - state.firstAt);
31
+ return {
32
+ ...state,
33
+ failingForMs,
34
+ shouldRestart: state.count >= requiredFailures && failingForMs >= windowMs
35
+ };
36
+ },
37
+
38
+ recordSuccess(pid) {
39
+ const recovered = state.pid === pid && state.count > 0
40
+ ? { ...state, failingForMs: Math.max(0, now() - state.firstAt) }
41
+ : null;
42
+ state = emptyFailureState();
43
+ return recovered;
44
+ },
45
+
46
+ reset() {
47
+ state = emptyFailureState();
48
+ },
49
+
50
+ snapshot() {
51
+ return { ...state };
52
+ }
53
+ };
54
+ }
55
+
56
+ function createSingleFlight() {
57
+ let pending = null;
58
+
59
+ return {
60
+ run(work) {
61
+ if (pending) return pending;
62
+ if (typeof work !== 'function') {
63
+ throw new TypeError('work must be a function');
64
+ }
65
+ const current = Promise.resolve().then(work);
66
+ pending = current;
67
+ const clear = () => {
68
+ if (pending === current) pending = null;
69
+ };
70
+ current.then(clear, clear);
71
+ return current;
72
+ },
73
+
74
+ isRunning() {
75
+ return pending !== null;
76
+ }
77
+ };
78
+ }
79
+
80
+ function sameHostIncarnation(a, b) {
81
+ if (!a || !b) return false;
82
+ if (Number(a.pid) !== Number(b.pid) || Number(a.port) !== Number(b.port)) {
83
+ return false;
84
+ }
85
+ const aStartedAt = Number(a.startedAt);
86
+ const bStartedAt = Number(b.startedAt);
87
+ if (aStartedAt > 0 && bStartedAt > 0) {
88
+ return aStartedAt === bStartedAt;
89
+ }
90
+ return true;
91
+ }
92
+
93
+ module.exports = {
94
+ createFailureTracker,
95
+ createSingleFlight,
96
+ sameHostIncarnation
97
+ };
package/mcp/hop-mcp.js CHANGED
@@ -4901,7 +4901,12 @@ class HopMCPServer {
4901
4901
  try {
4902
4902
  const listed = await this.callApi('GET', '/api/sessions');
4903
4903
  if (listed && Array.isArray(listed.sessions)) {
4904
- const match = listed.sessions.find((s) => s && (s.name === name || s.displayName === name || s.internalName === name));
4904
+ const nameLower = name.toLowerCase();
4905
+ const foldsTo = (v) => typeof v === 'string' && v.toLowerCase() === nameLower;
4906
+ const exact = listed.sessions.find((s) => s && (s.name === name || s.displayName === name || s.internalName === name));
4907
+ // Case-insensitive fallback (unique match only) — same rule as the daemon.
4908
+ const folded = exact ? [] : listed.sessions.filter((s) => s && (foldsTo(s.name) || foldsTo(s.displayName) || foldsTo(s.internalName)));
4909
+ const match = exact || (folded.length === 1 ? folded[0] : null);
4905
4910
  if (match) {
4906
4911
  matched = true;
4907
4912
  internalName = match.internalName || match.name || name;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
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": {
@@ -18,6 +18,27 @@
18
18
 
19
19
  const fs = require("node:fs");
20
20
  const os = require("node:os");
21
+
22
+ // Crash-safe write: temp + fsync + atomic rename. Records are the restore
23
+ // inventory — an un-fsynced page cache at power loss must not be able to
24
+ // take a conversation's identity with it (the 07-29 thermal-sleep crash
25
+ // erased three days of plainly-written files while fsynced ones survived).
26
+ function writeDurable(file, data) {
27
+ try {
28
+ const tmp = `${file}.tmp-${process.pid}`;
29
+ const fd = fs.openSync(tmp, "w", 0o600);
30
+ try {
31
+ fs.writeSync(fd, data);
32
+ fs.fsyncSync(fd);
33
+ } finally {
34
+ fs.closeSync(fd);
35
+ }
36
+ fs.renameSync(tmp, file);
37
+ } catch {
38
+ try { fs.writeFileSync(file, data, { mode: 0o600 }); } catch { /* never disrupt claude */ }
39
+ }
40
+ }
41
+
21
42
  const path = require("node:path");
22
43
  const { execFileSync } = require("node:child_process");
23
44
 
@@ -86,13 +107,13 @@ function recordSession(dir, hopSession, payload) {
86
107
  const prev = JSON.parse(prevRaw);
87
108
  if (prev && prev.sessionId && prev.sessionId !== sessionId && prev.cwd && prev.cwd !== cwd) {
88
109
  try {
89
- fs.writeFileSync(path.join(dir, `${hopSession}.other.json`), record, { mode: 0o600 });
110
+ writeDurable(path.join(dir, `${hopSession}.other.json`), record);
90
111
  } catch { /* ignore */ }
91
112
  return;
92
113
  }
93
114
  } catch { /* no incumbent (or unreadable) — record normally */ }
94
115
  try {
95
- fs.writeFileSync(file, record, { mode: 0o600 });
116
+ writeDurable(file, record);
96
117
  } catch { /* ignore */ }
97
118
  }
98
119
 
@@ -109,7 +130,7 @@ function bumpTurn(dir, hopSession, payload) {
109
130
  at: new Date().toISOString()
110
131
  });
111
132
  try {
112
- fs.writeFileSync(file, record, { mode: 0o600 });
133
+ writeDurable(file, record);
113
134
  } catch { /* ignore */ }
114
135
  }
115
136
 
@@ -370,7 +370,9 @@ async function main() {
370
370
  cols,
371
371
  rows,
372
372
  // Per-connection snapshot cap (monitor tiles ask small).
373
- replayBytes: Number.isFinite(replayRaw) && replayRaw > 0 ? replayRaw : undefined,
373
+ // 0 passes through as an explicit "no snapshot" — dropping it
374
+ // to undefined made claim sockets pull the full default tail.
375
+ replayBytes: Number.isFinite(replayRaw) && replayRaw >= 0 ? replayRaw : undefined,
374
376
  // nudge=0: viewer already shows the current grid (wall tile)
375
377
  nudge: wsUrl.searchParams.get('nudge') === '0' ? false : undefined
376
378
  },
@@ -378,6 +380,17 @@ async function main() {
378
380
  );
379
381
  });
380
382
 
383
+ // Periodic notekeeping: the shutdown handler only runs on a GRACEFUL
384
+ // exit, so a crash (thermal sleep, power cut, SIGKILL) used to leave no
385
+ // buffers at all — `hop restore` had screens to replay only when the
386
+ // stop was polite. Flush the same bounded tails every few minutes; a
387
+ // crash now costs at most the last interval of scrollback.
388
+ const bufferFlushTimer = setInterval(() => {
389
+ if (shuttingDown) return;
390
+ try { persistRoomBuffers(rooms); } catch (e) { /* next tick */ }
391
+ }, 4 * 60 * 1000);
392
+ bufferFlushTimer.unref?.();
393
+
381
394
  const shutdown = () => {
382
395
  shuttingDown = true; // keep restore records for rooms killed by this shutdown
383
396
  // Persist tail buffers BEFORE closeAll() kills the PTYs, so a graceful
@@ -68,6 +68,50 @@ const salvageRoom = (port, id) => new Promise((resolve) => {
68
68
 
69
69
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
70
70
 
71
+ // Daemon API (Bearer-token) — used for the pre/post state census: room IDs
72
+ // alone can't tell whether RENAMES and FOLDERS survived, and "restored" must
73
+ // mean the user-visible state came back, not merely that a PTY exists.
74
+ const daemonApi = (method, p, body) => new Promise((resolve) => {
75
+ const ds = daemonState();
76
+ const data = body ? JSON.stringify(body) : null;
77
+ const req = http.request({
78
+ host: '127.0.0.1', port: ds.port, path: p, method,
79
+ headers: { Authorization: 'Bearer ' + ds.sessionSecret, ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}) }
80
+ }, (res) => { let d = ''; res.on('data', (c) => d += c); res.on('end', () => resolve({ status: res.statusCode, data: d })); });
81
+ req.on('error', () => resolve({ status: 0, data: '' }));
82
+ // destroy() WITH an error: a bare destroy() emits no 'error' event, the
83
+ // promise never settles, and the whole run hangs silently on one slow
84
+ // request (exactly what stranded the first verified restart at RECONCILE).
85
+ req.setTimeout(20000, () => req.destroy(new Error('timeout')));
86
+ if (data) req.end(data); else req.end();
87
+ });
88
+
89
+ const sessionCensus = async () => {
90
+ const res = await daemonApi('GET', '/api/sessions');
91
+ try {
92
+ const sessions = JSON.parse(res.data).sessions || [];
93
+ const map = {};
94
+ for (const s of sessions) {
95
+ if (!s || s.type === 'port') continue;
96
+ map[s.internalName || s.name] = {
97
+ displayName: s.displayName || s.name,
98
+ folderId: s.folderId || null,
99
+ live: !!s.live
100
+ };
101
+ }
102
+ return map;
103
+ } catch (e) { return null; }
104
+ };
105
+
106
+ // Same screen-witness the restore machinery trusts: claude's own chrome.
107
+ const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc to interrupt|\? for shortcuts/i;
108
+ const RESUME_PROMPT_RE = /Resuming the full session|Resume from summary|Resume full session as-is/i;
109
+
110
+ const roomPreviewText = async (id) => {
111
+ const res = await daemonApi('GET', `/api/sessions/preview?name=${encodeURIComponent(id)}`);
112
+ try { return String(JSON.parse(res.data).text || ''); } catch (e) { return ''; }
113
+ };
114
+
71
115
  // Same lifecycle lock the hop CLI takes (~/.hop2/.lifecycle.lock): this run
72
116
  // cycles the host, and a `hop stop`/`start`/`restore` landing in the middle
73
117
  // of it kills sessions mid-relaunch. Stale holders (dead pid, absurdly old)
@@ -128,6 +172,12 @@ const releaseLock = () => {
128
172
  const preRooms = (await hostGet('/rooms')).rooms;
129
173
  const rooms = preRooms.map((r) => r.id);
130
174
  fs.writeFileSync(path.join(OUT, 'pre-rooms.json'), JSON.stringify(preRooms, null, 1), { mode: 0o600 });
175
+ // Daemon-side census too: display names and folder membership are the
176
+ // state the user actually sees, and "back to the same state" is judged
177
+ // against THIS, not against bare room ids.
178
+ const preSessions = await sessionCensus();
179
+ if (preSessions) fs.writeFileSync(path.join(OUT, 'pre-sessions.json'), JSON.stringify(preSessions, null, 1), { mode: 0o600 });
180
+ else log('WARN: pre-restart daemon census unavailable — name/folder verification will be skipped');
131
181
  log(`salvaging ${rooms.length} rooms -> ${OUT}`);
132
182
  const salvageStats = {};
133
183
  for (const id of rooms) {
@@ -173,6 +223,72 @@ const releaseLock = () => {
173
223
  fs.writeFileSync(path.join(OUT, 'reconcile.json'),
174
224
  JSON.stringify({ preCount: rooms.length, restored, missing, extra, salvageMisses }, null, 1), { mode: 0o600 });
175
225
  } catch (e) { log('reconcile err: ' + e.message); }
226
+
227
+ // 4b. STATE verification: names, folders, and claude actually resumed.
228
+ // A room can exist while wearing the wrong name, sitting in no folder,
229
+ // or stuck at claude's resume question — each of those has happened, and
230
+ // each reads as "restore worked" to a bare room-id reconcile.
231
+ try {
232
+ const preSessions = JSON.parse(fs.readFileSync(path.join(OUT, 'pre-sessions.json'), 'utf8'));
233
+ const claudeRooms = rooms.filter((id) =>
234
+ fs.existsSync(path.join(HOME, 'claude-sessions', `${id}.json`)));
235
+ const problems = [];
236
+ const post = await sessionCensus() || {};
237
+ for (const [id, pre] of Object.entries(preSessions)) {
238
+ if (!pre.live) continue; // dead-at-shutdown sessions owe nothing
239
+ const cur = post[id];
240
+ if (!cur || !cur.live) { problems.push(`${id}: not live after restore`); continue; }
241
+ if (cur.displayName !== pre.displayName) problems.push(`${id}: name "${pre.displayName}" → "${cur.displayName}"`);
242
+ if ((cur.folderId || null) !== (pre.folderId || null)) problems.push(`${id}: folder ${pre.folderId || '(none)'} → ${cur.folderId || '(none)'}`);
243
+ }
244
+ // Claude resume witness: poll each claude room's screen for claude's
245
+ // own chrome; a lingering resume question gets answered by the same
246
+ // command a human would use, then re-checked.
247
+ let pendingClaude = new Set(claudeRooms);
248
+ let resumeNudged = false;
249
+ for (let i = 0; i < 18 && pendingClaude.size > 0; i++) {
250
+ for (const id of [...pendingClaude]) {
251
+ const text = await roomPreviewText(id);
252
+ if (CLAUDE_UI_RE.test(text) && !RESUME_PROMPT_RE.test(text)) { pendingClaude.delete(id); continue; }
253
+ if (RESUME_PROMPT_RE.test(text) && !resumeNudged) {
254
+ resumeNudged = true;
255
+ log('resume question on screen — running hop resume-waiting');
256
+ try { log(execSync('hop resume-waiting 2>&1 | tail -5', { encoding: 'utf8', timeout: 90000 })); } catch (e) { log('resume-waiting err: ' + e.message); }
257
+ }
258
+ }
259
+ if (pendingClaude.size > 0) await sleep(5000);
260
+ }
261
+ for (const id of pendingClaude) problems.push(`${id}: claude chrome never appeared (still starting, or resume failed)`);
262
+ log(`VERIFY: ${Object.keys(preSessions).filter((k) => preSessions[k].live).length} live sessions checked, ` +
263
+ `${claudeRooms.length} claude rooms witnessed, ${problems.length} problem(s)`);
264
+ for (const p of problems) log(`VERIFY FAIL: ${p}`);
265
+ if (problems.length === 0) log('VERIFY: state matches the pre-restart census — names, folders, claude all back ✓');
266
+ fs.writeFileSync(path.join(OUT, 'verification.json'), JSON.stringify({ problems, claudeRooms }, null, 1), { mode: 0o600 });
267
+ } catch (e) { log('verify err: ' + (e && e.stack || e)); }
268
+
269
+ // 4c. Serialized-attach probe: the whole point of this cycle is the new
270
+ // host code — witness a serialized snapshot (in-band RIS prefix) on a
271
+ // bounded-replay attach, exactly as a wall tile would connect.
272
+ try {
273
+ const probeRoom = rooms.find((id) => id !== 'Solstice') || rooms[0];
274
+ const nh = hostState();
275
+ const result = await new Promise((resolve) => {
276
+ const ws = new WebSocket(`ws://127.0.0.1:${nh.port}/ws?room=${encodeURIComponent(probeRoom)}&name=verify&cols=100&rows=30&replay=65536&nudge=0`);
277
+ const timer = setTimeout(() => { try { ws.close(); } catch (e) {} resolve('timeout'); }, 15000);
278
+ ws.on('message', (data) => {
279
+ try {
280
+ const msg = JSON.parse(String(data));
281
+ if (msg.type === 'snapshot') {
282
+ clearTimeout(timer);
283
+ try { ws.close(); } catch (e) {}
284
+ resolve(msg.data.startsWith('\x1bc') ? `serialized (${msg.data.length}B, capped=${msg.capped === true})` : `raw (${msg.data.length}B)`);
285
+ }
286
+ } catch (e) { /* keep waiting */ }
287
+ });
288
+ ws.on('error', () => { clearTimeout(timer); resolve('ws-error'); });
289
+ });
290
+ log(`serialized attach probe on ${probeRoom}: ${result}${String(result).startsWith('serialized') ? ' ✓ ACTIVE' : ' — NOT active'}`);
291
+ } catch (e) { log('serialized probe err: ' + e.message); }
176
292
  const ds = daemonState();
177
293
  const api = (method, p, body) => new Promise((resolve) => {
178
294
  const data = body ? JSON.stringify(body) : null;
@@ -0,0 +1,109 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const {
4
+ createFailureTracker,
5
+ createSingleFlight,
6
+ sameHostIncarnation
7
+ } = require('../lib/hay-host-watchdog');
8
+
9
+ test('a successful probe resets the complete failure streak', () => {
10
+ let now = 0;
11
+ const tracker = createFailureTracker({
12
+ requiredFailures: 3,
13
+ windowMs: 60_000,
14
+ now: () => now
15
+ });
16
+
17
+ assert.equal(tracker.recordFailure(42).shouldRestart, false);
18
+ now = 17_000;
19
+ assert.equal(tracker.recordFailure(42).shouldRestart, false);
20
+
21
+ now = 53_000;
22
+ const recovered = tracker.recordSuccess(42);
23
+ assert.equal(recovered.count, 2);
24
+ assert.deepEqual(tracker.snapshot(), { pid: 0, count: 0, firstAt: 0 });
25
+
26
+ now = 62_000;
27
+ const nextFailure = tracker.recordFailure(42);
28
+ assert.equal(nextFailure.count, 1);
29
+ assert.equal(nextFailure.failingForMs, 0);
30
+ assert.equal(nextFailure.shouldRestart, false);
31
+ });
32
+
33
+ test('restart requires enough consecutive failures spanning the full window', () => {
34
+ let now = 0;
35
+ const tracker = createFailureTracker({
36
+ requiredFailures: 3,
37
+ windowMs: 60_000,
38
+ now: () => now
39
+ });
40
+
41
+ tracker.recordFailure(7);
42
+ now = 20_000;
43
+ tracker.recordFailure(7);
44
+ now = 59_999;
45
+ assert.equal(tracker.recordFailure(7).shouldRestart, false);
46
+ now = 60_000;
47
+ assert.equal(tracker.recordFailure(7).shouldRestart, true);
48
+ });
49
+
50
+ test('failure history never crosses host incarnations', () => {
51
+ let now = 0;
52
+ const tracker = createFailureTracker({
53
+ requiredFailures: 3,
54
+ windowMs: 60_000,
55
+ now: () => now
56
+ });
57
+
58
+ tracker.recordFailure(7);
59
+ now = 61_000;
60
+ const firstFailureForReplacement = tracker.recordFailure(8);
61
+ assert.equal(firstFailureForReplacement.count, 1);
62
+ assert.equal(firstFailureForReplacement.failingForMs, 0);
63
+ assert.equal(firstFailureForReplacement.shouldRestart, false);
64
+ });
65
+
66
+ test('single flight shares one operation across concurrent callers and clears afterward', async () => {
67
+ const flight = createSingleFlight();
68
+ let starts = 0;
69
+ let release;
70
+ const blocked = new Promise((resolve) => { release = resolve; });
71
+ const work = async () => {
72
+ starts += 1;
73
+ await blocked;
74
+ return 'ready';
75
+ };
76
+
77
+ const callers = Array.from({ length: 15 }, () => flight.run(work));
78
+ assert.equal(starts, 0, 'work starts on the next microtask');
79
+ await Promise.resolve();
80
+ assert.equal(starts, 1);
81
+ assert.equal(new Set(callers).size, 1, 'all callers receive the same promise');
82
+
83
+ release();
84
+ assert.deepEqual(await Promise.all(callers), Array(15).fill('ready'));
85
+ assert.equal(flight.isRunning(), false);
86
+
87
+ assert.equal(await flight.run(async () => {
88
+ starts += 1;
89
+ return 'again';
90
+ }), 'again');
91
+ assert.equal(starts, 2);
92
+ });
93
+
94
+ test('single flight clears after a rejected operation so recovery can retry', async () => {
95
+ const flight = createSingleFlight();
96
+ await assert.rejects(flight.run(async () => {
97
+ throw new Error('probe failed');
98
+ }), /probe failed/);
99
+ assert.equal(flight.isRunning(), false);
100
+ assert.equal(await flight.run(async () => 'recovered'), 'recovered');
101
+ });
102
+
103
+ test('host incarnation comparison includes pid, port, and start time', () => {
104
+ const host = { pid: 7, port: 5000, startedAt: 100 };
105
+ assert.equal(sameHostIncarnation(host, { ...host }), true);
106
+ assert.equal(sameHostIncarnation(host, { ...host, pid: 8 }), false);
107
+ assert.equal(sameHostIncarnation(host, { ...host, port: 5001 }), false);
108
+ assert.equal(sameHostIncarnation(host, { ...host, startedAt: 101 }), false);
109
+ });
@@ -584,3 +584,103 @@ test('a display name freed by a later rename can be reused', async () => {
584
584
  await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Final', internalName: 'AliasOne' });
585
585
  await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Freed', internalName: 'AliasTwo' });
586
586
  });
587
+
588
+ // Regression: folder membership must survive every cache rebuild. The store
589
+ // entry is a CACHE any path may recreate without folderId (restore's fresh
590
+ // session definitions did exactly that), and buildSessionEntries' store loop
591
+ // read that raw cache — clobbering the correct value the durable meta (and
592
+ // the live-rooms loop) already knew. Nine of eleven folder assignments
593
+ // vanished from the UI after a host cycle while every meta on disk was right.
594
+ test('folder membership survives restore re-registration and a daemon restart', async () => {
595
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
596
+ { name: 'FolderKeeper', type: 'terminal', cwd: tempDir, startRuntime: true });
597
+ await delay(600);
598
+ const folder = await requestJson(state.port, state.sessionSecret, 'POST', '/api/folders', { name: 'KeeperShelf' });
599
+ assert.equal(folder.status, 200, `folder create failed: ${JSON.stringify(folder.data)}`);
600
+ const folderId = folder.data.folder.id;
601
+ const moved = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/move',
602
+ { name: 'FolderKeeper', folderId });
603
+ assert.equal(moved.status, 200, `move failed: ${JSON.stringify(moved.data)}`);
604
+
605
+ const listedNow = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
606
+ assert.equal(listedNow.data.sessions.find(s => s.internalName === 'FolderKeeper')?.folderId, folderId,
607
+ 'freshly moved session must list its folder');
608
+
609
+ // Restore re-registration recreates cached session definitions — the write
610
+ // that used to outrank the durable meta in the listing.
611
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/restore', {});
612
+ await delay(1200);
613
+ const afterRestore = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
614
+ assert.equal(afterRestore.data.sessions.find(s => s.internalName === 'FolderKeeper')?.folderId, folderId,
615
+ 'folder membership must survive restore re-registration');
616
+
617
+ const previousState = state;
618
+ await stopDaemon(true);
619
+ await launchDaemon(previousState);
620
+ const afterRestart = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
621
+ assert.equal(afterRestart.data.sessions.find(s => s.internalName === 'FolderKeeper')?.folderId, folderId,
622
+ 'folder membership must survive a daemon restart');
623
+
624
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'FolderKeeper', internalName: 'FolderKeeper' });
625
+ });
626
+
627
+ // Session names resolve case-insensitively everywhere a name addresses a
628
+ // session: exact-case wearers win, a UNIQUE folded match resolves, and two
629
+ // sessions differing only by case stay exact-only rather than guessed at.
630
+ test('session names resolve case-insensitively', async () => {
631
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
632
+ { name: 'CaseFold', type: 'terminal', cwd: tempDir, startRuntime: true });
633
+ await delay(600);
634
+
635
+ // A by-name API call accepts any casing — no internalName hint given.
636
+ const renamed = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
637
+ { oldName: 'casefold', newName: 'CaseKept' });
638
+ assert.equal(renamed.status, 200, `lowercase lookup must find CaseFold: ${JSON.stringify(renamed.data)}`);
639
+
640
+ // Changing ONLY the casing of your own name is not a collision.
641
+ const caseOnly = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
642
+ { oldName: 'CaseKept', newName: 'casekept' });
643
+ assert.equal(caseOnly.status, 200, `case-only rename must be allowed: ${JSON.stringify(caseOnly.data)}`);
644
+
645
+ const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
646
+ const worn = list.data.sessions.filter(s => s.internalName === 'CaseFold');
647
+ assert.equal(worn.length, 1, 'still exactly one session — folded lookups must never mint twins');
648
+ assert.equal(worn[0].displayName, 'casekept', 'the case-only rename is the worn spelling');
649
+
650
+ // A name another session wears — in ANY casing — is a collision.
651
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
652
+ { name: 'CaseOther', type: 'terminal', cwd: tempDir, startRuntime: true });
653
+ await delay(400);
654
+ const collide = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
655
+ { oldName: 'CaseOther', newName: 'CASEKEPT', internalName: 'CaseOther' });
656
+ assert.equal(collide.status, 409, 'a differently-cased spelling of a worn name must be refused');
657
+
658
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'casekept', internalName: 'CaseFold' });
659
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CaseOther', internalName: 'CaseOther' });
660
+ });
661
+
662
+ // Regression: an undeclared Bearer-token creation is automation, not a human.
663
+ // Agents' raw scripts rarely remember x-hop-actor, and every one that forgot
664
+ // landed its sessions in the USER tab (SelectProbe). Cookie-auth (the web UI)
665
+ // and the hop CLI's via stamp still default to user; explicit headers win.
666
+ test('bare token API creations default to agent origin', async () => {
667
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
668
+ { name: 'BareTokenProbe', type: 'terminal', cwd: tempDir, startRuntime: true });
669
+ await delay(400);
670
+ const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
671
+ const s = list.data.sessions.find(x => x.internalName === 'BareTokenProbe');
672
+ assert.ok(s, 'session exists');
673
+ assert.equal(s.createdBy, 'agent', `undeclared token caller must be agent, got ${s.createdBy}`);
674
+
675
+ // The hop CLI's own via stamp keeps humans human.
676
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
677
+ { name: 'CliUserProbe', type: 'terminal', cwd: tempDir, startRuntime: true },
678
+ { 'X-Hop-Via': 'cli' });
679
+ await delay(400);
680
+ const list2 = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
681
+ const s2 = list2.data.sessions.find(x => x.internalName === 'CliUserProbe');
682
+ assert.equal(s2 && s2.createdBy, 'user', 'CLI-stamped caller stays user');
683
+
684
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'BareTokenProbe', internalName: 'BareTokenProbe' });
685
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CliUserProbe', internalName: 'CliUserProbe' });
686
+ });