hop2 0.11.2 → 0.11.3

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.
@@ -6,8 +6,8 @@
6
6
  <!-- Terminal prompt chevron on a dark tile — reads "terminal app" at 16px. -->
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2317181d'/%3E%3Cpath d='M11 9.5 L20.5 16 L11 22.5' fill='none' stroke='%23a78bfa' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
8
8
  <title>Hay</title>
9
- <script type="module" crossorigin src="/assets/index-DfGT3DuO.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BohAWpcL.css">
9
+ <script type="module" crossorigin src="/assets/index-BrLbmVOB.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BRpBf8QE.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/hop CHANGED
@@ -658,13 +658,60 @@ function getRuntimeSessionMetadata(name) {
658
658
  return runtimeSessionMetadata.get(name) || null;
659
659
  }
660
660
 
661
+ // Durable meta is read on every effective-config lookup, so a tiny cache
662
+ // keeps it off the hot path. Invalidated by mtime, so an external write
663
+ // (the hook, another process) is picked up without a restart.
664
+ const durableIdentityCache = new Map();
665
+ function readDurableIdentity(name) {
666
+ if (!name || !SESSION_NAME_RE.test(name)) return null;
667
+ let mtimeMs = 0;
668
+ try {
669
+ mtimeMs = fs.statSync(path.join(CLAUDE_SESSIONS_DIR, `${name}.meta`)).mtimeMs;
670
+ } catch (e) {
671
+ durableIdentityCache.delete(name);
672
+ return null;
673
+ }
674
+ const hit = durableIdentityCache.get(name);
675
+ if (hit && hit.mtimeMs === mtimeMs) return hit.value;
676
+ const value = readClaudeSessionMeta(name);
677
+ durableIdentityCache.set(name, { mtimeMs, value });
678
+ return value;
679
+ }
680
+
681
+ // THE identity accessor. Everything that asks "what is this session called,
682
+ // where is it filed, who made it" comes through here.
683
+ //
684
+ // The layers are not peers. saved/runtime configs are CACHES that any code
685
+ // path may recreate with defaults — `hop restore` writes one per session
686
+ // with the INTERNAL name — while durable meta is where a user's own answers
687
+ // are recorded and the only layer that survives the daemon. Merging
688
+ // saved-over-runtime and stopping there meant a crash + restore silently
689
+ // reverted every rename and un-filed every folder, because restore's freshly
690
+ // written cache outranked the file that knew better. Meta wins for the
691
+ // user-authored fields; everything else keeps the old precedence.
661
692
  function getEffectiveSessionConfig(name) {
662
693
  const saved = getSessionConfig(name);
663
694
  const runtime = getRuntimeSessionMetadata(name);
664
- if (saved && runtime) {
665
- return { ...runtime, ...saved };
695
+ const base = saved && runtime ? { ...runtime, ...saved } : (saved || runtime || null);
696
+ const durable = readDurableIdentity(name);
697
+ if (!durable) return base;
698
+ if (!base) {
699
+ return {
700
+ type: 'terminal',
701
+ displayName: durable.displayName,
702
+ folderId: durable.folderId,
703
+ createdBy: durable.createdBy,
704
+ cwd: durable.cwd
705
+ };
666
706
  }
667
- return saved || runtime || null;
707
+ const merged = { ...base };
708
+ if (durable.displayName) merged.displayName = durable.displayName;
709
+ if (durable.folderId !== undefined) merged.folderId = durable.folderId || null;
710
+ if (durable.createdBy) merged.createdBy = durable.createdBy;
711
+ if (durable.tagline) merged.tagline = durable.tagline;
712
+ if (durable.parked !== undefined) merged.parked = durable.parked;
713
+ if (durable.archived !== undefined) merged.archived = durable.archived;
714
+ return merged;
668
715
  }
669
716
 
670
717
  function hasSavedSessionConfig(name) {
@@ -1994,6 +2041,9 @@ function saveHopConfig(config) {
1994
2041
  // session should be the session you left, not a summary of it. Set
1995
2042
  // resume_mode to "summary" (cheaper) or "ask" (leave the prompt alone).
1996
2043
  const RESUME_PROMPT_RE = /Resuming the full session|Resume from summary|Resume full session as-is/i;
2044
+ // Claude Code's own chrome — proof the app is up even when the room reports
2045
+ // the wrapper shell as its foreground process (argv launch).
2046
+ const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc to interrupt|\? for shortcuts/i;
1997
2047
  function getResumeMode() {
1998
2048
  const mode = String(getHopConfig().resume_mode || '').toLowerCase();
1999
2049
  return mode === 'summary' || mode === 'ask' ? mode : 'full';
@@ -3839,7 +3889,9 @@ function writeClaudeSessionRecord(internalName, rec) {
3839
3889
  if (!internalName || !SESSION_NAME_RE.test(internalName)) return;
3840
3890
  try { fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true }); } catch (e) { /* best effort */ }
3841
3891
  try {
3842
- fs.writeFileSync(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.json`), JSON.stringify(rec), { mode: 0o600 });
3892
+ // Same crash-safety as the metas: records are what lets a claude
3893
+ // conversation resume at all.
3894
+ writePrivateFile(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.json`), JSON.stringify(rec), 0o600);
3843
3895
  } catch (e) { /* best effort */ }
3844
3896
  }
3845
3897
 
@@ -3881,7 +3933,14 @@ function writeClaudeSessionMeta(internalName, meta) {
3881
3933
  if (!internalName || !SESSION_NAME_RE.test(internalName)) return;
3882
3934
  try { fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true }); } catch (e) { /* best effort */ }
3883
3935
  try {
3884
- fs.writeFileSync(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.meta`), JSON.stringify(meta), { mode: 0o600 });
3936
+ // ATOMIC + FSYNCED, not writeFileSync. The thermal-sleep crash proved
3937
+ // the difference with a natural experiment: folders.json (written via
3938
+ // writePrivateFile the same night) SURVIVED, while every meta
3939
+ // rewritten in the prior three days — every rename, every folder
3940
+ // filing — vanished with the un-fsynced page cache. These files are
3941
+ // the restore inventory; they are exactly the data that must outlive
3942
+ // a power cut.
3943
+ writePrivateFile(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.meta`), JSON.stringify(meta), 0o600);
3885
3944
  } catch (e) { /* best effort */ }
3886
3945
  }
3887
3946
 
@@ -4276,6 +4335,19 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
4276
4335
  // a second copy on top of it (that is how command lines piled
4277
4336
  // up in a shell that was busy booting).
4278
4337
  if (running) { finish('already-running'); continue; }
4338
+ // Same blindness applies before typing: a session whose
4339
+ // command was launched as argv shows a shell here while
4340
+ // claude runs beneath it. Typing again would land keystrokes
4341
+ // in claude's own prompt.
4342
+ try {
4343
+ const grid = await getRoomGridPreviewShared(job.plan.internalName);
4344
+ const text = String(grid?.text || '');
4345
+ if (RESUME_PROMPT_RE.test(text) || CLAUDE_UI_RE.test(text)) {
4346
+ job.state = 'verify';
4347
+ job.since = Date.now();
4348
+ continue;
4349
+ }
4350
+ } catch (e) { /* fall through to typing */ }
4279
4351
  // A fresh shell reports its own name once it has execed; that
4280
4352
  // is our prompt-ready signal, and it beats guessing with a timer.
4281
4353
  if (Date.now() - job.since < 400) continue;
@@ -4287,7 +4359,25 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
4287
4359
  continue;
4288
4360
  }
4289
4361
  if (job.state === 'verify') {
4290
- if (running) {
4362
+ // "Is it running?" cannot be answered by the foreground
4363
+ // process alone. Argv launch runs `shell -lc "<cmd>; exec
4364
+ // shell -l"`, so the foreground process is the WRAPPER SHELL
4365
+ // for the whole life of the command — the very mechanism that
4366
+ // replaced typing made the process check blind. After the
4367
+ // crash restore, claude was sitting at its own resume prompt
4368
+ // while this loop reported "never-started", retyped the
4369
+ // command into that prompt, and gave up without answering it.
4370
+ // The screen is the honest witness: a resume prompt, or
4371
+ // claude's own chrome, means it is up.
4372
+ let screenSaysRunning = false;
4373
+ if (!running) {
4374
+ try {
4375
+ const grid = await getRoomGridPreviewShared(job.plan.internalName);
4376
+ const text = String(grid?.text || '');
4377
+ screenSaysRunning = RESUME_PROMPT_RE.test(text) || CLAUDE_UI_RE.test(text);
4378
+ } catch (e) { /* host busy — try again next tick */ }
4379
+ }
4380
+ if (running || screenSaysRunning) {
4291
4381
  const key = resumeChoiceKey(getResumeMode());
4292
4382
  if (!key) { finish('resumed'); continue; }
4293
4383
  job.state = 'answer-prompt';
@@ -4990,6 +5080,14 @@ const resolveVia = (req) => {
4990
5080
  };
4991
5081
 
4992
5082
  function resolveActor(req) {
5083
+ const declared = String(req?.headers?.[ACTOR_HEADER] || '').toLowerCase();
5084
+ if (declared === 'agent' || declared === 'user') return declared;
5085
+ // Undeclared: infer from the auth path. Cookie = a human in the web UI.
5086
+ // Bearer token without the hop CLI's via stamp = automation.
5087
+ if (req?.hopAuthVia === 'token') {
5088
+ const via = String(req?.headers?.[VIA_HEADER] || '').toLowerCase();
5089
+ if (via !== 'cli' && via !== 'web') return 'agent';
5090
+ }
4993
5091
  return resolveActorFromHeaders(req?.headers || {}, ACTOR_HEADER);
4994
5092
  }
4995
5093
 
@@ -6077,9 +6175,25 @@ function syncSessionMetaFromRooms(rooms) {
6077
6175
  for (const room of rooms) {
6078
6176
  if (!room || typeof room.id !== 'string' || !SESSION_NAME_RE.test(room.id)) continue;
6079
6177
  const cwd = typeof room.cwd === 'string' ? room.cwd : '';
6080
- if (!cwd) continue;
6081
6178
  const existing = readClaudeSessionMeta(room.id);
6082
- if (existing && existing.cwd === cwd) continue;
6179
+ // A room with no cwd used to be skipped entirely — so it never got a
6180
+ // durable meta, and a session that only ever lived in the host's
6181
+ // memory was UNRECOVERABLE the moment the host died: `hop restore`
6182
+ // reads this directory, and a session absent from it cannot even be
6183
+ // named, let alone relaunched. That is how a thermal-sleep crash took
6184
+ // ten sessions with no trace. Every live room now leaves a record;
6185
+ // cwd is filled in when it becomes known.
6186
+ if (!existing) {
6187
+ writeClaudeSessionMeta(room.id, {
6188
+ internalName: room.id,
6189
+ ...(cwd ? { cwd } : {}),
6190
+ createdAt: new Date().toISOString(),
6191
+ updatedAt: new Date().toISOString()
6192
+ });
6193
+ continue;
6194
+ }
6195
+ if (!cwd) continue;
6196
+ if (existing.cwd === cwd) continue;
6083
6197
  // Carry an origin we already know; never invent one. This pass exists
6084
6198
  // to backfill cwd, and defaulting createdBy to 'user' here silently
6085
6199
  // stamped "human" onto every agent session whose origin lived only in
@@ -6299,6 +6413,28 @@ async function ensureExternalHayHostReady() {
6299
6413
  }
6300
6414
  try {
6301
6415
 
6416
+ // Last look before spawning: an already-running, healthy host for THIS hop
6417
+ // home means we must adopt it, not add a rival. Two hosts split the fleet —
6418
+ // the daemon canonicalizes one, so every room on the loser goes invisible
6419
+ // and dies unrecorded when it is reaped — and both thrash the same state
6420
+ // files. The spawn lock above only serializes racing spawners; a host that
6421
+ // survived a crash (or was started by hand) is outside its scope, which is
6422
+ // how two hosts came to exist across the thermal-sleep crash.
6423
+ try {
6424
+ const survivors = (await inspectExternalHayHosts(true))
6425
+ .filter((entry) => entry.healthy && hostBelongsToCurrentHome(entry, null));
6426
+ const adoptable = survivors
6427
+ .map((entry) => entry.host)
6428
+ .filter((host) => host && Number.isInteger(host.pid) && isProcessAlive(host.pid))
6429
+ .sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))[0];
6430
+ if (adoptable) {
6431
+ log(`[host] adopting the running host (pid ${adoptable.pid}, port ${adoptable.port}) instead of spawning a rival`, DIM);
6432
+ writeHayHostStateFile(adoptable);
6433
+ hayHostState = adoptable;
6434
+ return adoptable;
6435
+ }
6436
+ } catch (e) { /* inspection failed — spawning is still the right move */ }
6437
+
6302
6438
  const hostScript = path.join(__dirname, 'scripts', 'hay-host.js');
6303
6439
  if (!fs.existsSync(hostScript)) {
6304
6440
  throw new Error('Hay host script missing');
@@ -6940,6 +7076,14 @@ function startAuthProxy(authEmitter) {
6940
7076
  : null;
6941
7077
  const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET
6942
7078
  || tokenFromHeader === SESSION_SECRET;
7079
+ // How this request proved itself decides the DEFAULT actor. Humans
7080
+ // reach the API through the web UI (cookie) or the hop CLI (which
7081
+ // stamps x-hop-via); a raw Bearer-token call with neither is, in
7082
+ // practice, always automation — an agent's script, a probe, a cron.
7083
+ // Those callers rarely remember to declare x-hop-actor, and every
7084
+ // one that forgot used to land its sessions in the USER tab
7085
+ // (SelectProbe et al). Explicit headers still win outright.
7086
+ req.hopAuthVia = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET ? 'cookie' : 'token';
6943
7087
 
6944
7088
  // API: Auth status (used by local CLI to wait for remote login)
6945
7089
  if (parsedUrl.pathname === '/api/auth' && req.method === 'GET') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
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
 
@@ -378,6 +378,17 @@ async function main() {
378
378
  );
379
379
  });
380
380
 
381
+ // Periodic notekeeping: the shutdown handler only runs on a GRACEFUL
382
+ // exit, so a crash (thermal sleep, power cut, SIGKILL) used to leave no
383
+ // buffers at all — `hop restore` had screens to replay only when the
384
+ // stop was polite. Flush the same bounded tails every few minutes; a
385
+ // crash now costs at most the last interval of scrollback.
386
+ const bufferFlushTimer = setInterval(() => {
387
+ if (shuttingDown) return;
388
+ try { persistRoomBuffers(rooms); } catch (e) { /* next tick */ }
389
+ }, 4 * 60 * 1000);
390
+ bufferFlushTimer.unref?.();
391
+
381
392
  const shutdown = () => {
382
393
  shuttingDown = true; // keep restore records for rooms killed by this shutdown
383
394
  // Persist tail buffers BEFORE closeAll() kills the PTYs, so a graceful
@@ -584,3 +584,29 @@ 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: an undeclared Bearer-token creation is automation, not a human.
589
+ // Agents' raw scripts rarely remember x-hop-actor, and every one that forgot
590
+ // landed its sessions in the USER tab (SelectProbe). Cookie-auth (the web UI)
591
+ // and the hop CLI's via stamp still default to user; explicit headers win.
592
+ test('bare token API creations default to agent origin', async () => {
593
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
594
+ { name: 'BareTokenProbe', type: 'terminal', cwd: tempDir, startRuntime: true });
595
+ await delay(400);
596
+ const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
597
+ const s = list.data.sessions.find(x => x.internalName === 'BareTokenProbe');
598
+ assert.ok(s, 'session exists');
599
+ assert.equal(s.createdBy, 'agent', `undeclared token caller must be agent, got ${s.createdBy}`);
600
+
601
+ // The hop CLI's own via stamp keeps humans human.
602
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
603
+ { name: 'CliUserProbe', type: 'terminal', cwd: tempDir, startRuntime: true },
604
+ { 'X-Hop-Via': 'cli' });
605
+ await delay(400);
606
+ const list2 = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
607
+ const s2 = list2.data.sessions.find(x => x.internalName === 'CliUserProbe');
608
+ assert.equal(s2 && s2.createdBy, 'user', 'CLI-stamped caller stays user');
609
+
610
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'BareTokenProbe', internalName: 'BareTokenProbe' });
611
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CliUserProbe', internalName: 'CliUserProbe' });
612
+ });