hop2 0.11.12 → 0.11.13

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,7 +6,7 @@
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-DzlPn2In.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DnSA-VR6.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-DHotLq-D.css">
11
11
  </head>
12
12
  <body>
package/hop CHANGED
@@ -2875,6 +2875,10 @@ function unregisterTerminalStream(id, res) {
2875
2875
  streams.delete(res);
2876
2876
  if (streams.size === 0) {
2877
2877
  terminalApiStreams.delete(id);
2878
+ // The idle clock starts when the LAST stream closes — an agent that
2879
+ // held a stream for an hour was active the whole time.
2880
+ const entry = terminalApiSessions.get(id);
2881
+ if (entry) entry.lastTouchedAt = Date.now();
2878
2882
  }
2879
2883
  }
2880
2884
 
@@ -3675,6 +3679,39 @@ function createVirtualClientAdapter(onServerMessage) {
3675
3679
  };
3676
3680
  }
3677
3681
 
3682
+ // Fetch a terminal API entry AND stamp it as recently used — every API touch
3683
+ // (stream, write, resize, exec, delete) resets the idle clock the reaper
3684
+ // below runs on.
3685
+ function getTerminalApiEntryTouched(terminalId) {
3686
+ const entry = terminalApiSessions.get(terminalId);
3687
+ if (entry) entry.lastTouchedAt = Date.now();
3688
+ return entry;
3689
+ }
3690
+
3691
+ // Idle reaper for agent attaches. A virtual client is presence like any
3692
+ // other ("agent" in the roster) but has no socket that could die — an agent
3693
+ // that crashed or simply never called close would sit in a session's
3694
+ // presence forever. Idle = no API touch and no open SSE stream. Reaping is
3695
+ // safe for well-behaved agents: the MCP core re-attaches by session name on
3696
+ // the next op (recoverTerminalId), so a reaped handle costs one round-trip.
3697
+ const TERMINAL_API_IDLE_TTL_MS = Number(process.env.HOP_TERMINAL_API_IDLE_TTL_MS || 15 * 60 * 1000);
3698
+ const TERMINAL_API_SWEEP_MS = Number(process.env.HOP_TERMINAL_API_SWEEP_MS || 60 * 1000);
3699
+ if (DAEMON_FLAG && TERMINAL_API_IDLE_TTL_MS > 0) {
3700
+ setInterval(() => {
3701
+ const nowMs = Date.now();
3702
+ for (const entry of [...terminalApiSessions.values()]) {
3703
+ if (terminalApiStreams.has(entry.id)) continue; // live stream = in use
3704
+ const idleSince = entry.lastTouchedAt || Date.parse(entry.createdAt) || nowMs;
3705
+ if (nowMs - idleSince <= TERMINAL_API_IDLE_TTL_MS) continue;
3706
+ log(`[terminal-api] detached idle '${entry.actor}' terminal from ${entry.displayName || entry.sessionName} after ${Math.round((nowMs - idleSince) / 60000)}m`, DIM);
3707
+ try { entry.adapter.close(); } catch (e) { /* already closing */ }
3708
+ // adapter.close() detaches the room client and drops the entry;
3709
+ // belt-and-braces in case a close handler threw mid-chain.
3710
+ terminalApiSessions.delete(entry.id);
3711
+ }
3712
+ }, TERMINAL_API_SWEEP_MS).unref();
3713
+ }
3714
+
3678
3715
  function closeTerminalApiEntry(terminalId) {
3679
3716
  terminalApiSessions.delete(terminalId);
3680
3717
  const streams = terminalApiStreams.get(terminalId);
@@ -3803,7 +3840,8 @@ async function attachExternalTerminalApiClient(sessionName, displayName, opts =
3803
3840
  actor: opts.actor || 'user',
3804
3841
  clientId,
3805
3842
  adapter: adapterBundle,
3806
- createdAt: new Date().toISOString()
3843
+ createdAt: new Date().toISOString(),
3844
+ lastTouchedAt: Date.now()
3807
3845
  };
3808
3846
  terminalApiSessions.set(terminalId, sessionEntry);
3809
3847
  return sessionEntry;
@@ -3851,7 +3889,8 @@ async function attachTerminalApiClient(sessionName, displayName, opts = {}) {
3851
3889
  actor: opts.actor || 'user',
3852
3890
  clientId,
3853
3891
  adapter: adapterBundle,
3854
- createdAt: new Date().toISOString()
3892
+ createdAt: new Date().toISOString(),
3893
+ lastTouchedAt: Date.now()
3855
3894
  };
3856
3895
  terminalApiSessions.set(terminalId, sessionEntry);
3857
3896
  return sessionEntry;
@@ -7415,6 +7454,30 @@ function recordAuth(authEmitter) {
7415
7454
  if (authEmitter) authEmitter.emit('authenticated');
7416
7455
  }
7417
7456
 
7457
+ // One MIME map for every static-serving path (hub assets, session assets,
7458
+ // Views files) — drifting per-branch copies are how .pdf went missing and a
7459
+ // published PDF downloaded as a blob. Includes the `hop view` families
7460
+ // (pdf/video/txt) alongside the app bundle's own types.
7461
+ const STATIC_CONTENT_TYPES = {
7462
+ '.html': 'text/html; charset=utf-8',
7463
+ '.js': 'application/javascript',
7464
+ '.css': 'text/css',
7465
+ '.json': 'application/json',
7466
+ '.png': 'image/png',
7467
+ '.jpg': 'image/jpeg',
7468
+ '.jpeg': 'image/jpeg',
7469
+ '.gif': 'image/gif',
7470
+ '.webp': 'image/webp',
7471
+ '.svg': 'image/svg+xml',
7472
+ '.ico': 'image/x-icon',
7473
+ '.pdf': 'application/pdf',
7474
+ '.mp4': 'video/mp4',
7475
+ '.mov': 'video/quicktime',
7476
+ '.webm': 'video/webm',
7477
+ '.txt': 'text/plain; charset=utf-8',
7478
+ '.md': 'text/plain; charset=utf-8'
7479
+ };
7480
+
7418
7481
  function startAuthProxy(authEmitter) {
7419
7482
  log(`[2/4] Starting Session Manager on port ${PROXY_PORT}...`, BOLD);
7420
7483
 
@@ -7424,6 +7487,27 @@ function startAuthProxy(authEmitter) {
7424
7487
  const ip = getClientIp(req);
7425
7488
  const sessionInfo = parseSessionFromRequest(req, parsedUrl);
7426
7489
 
7490
+ // Shared caches must never store what the cookie gate protects.
7491
+ // Cloudflare edge-caches common extensions (.pdf, images, .js) BY
7492
+ // DEFAULT when the origin stays silent, then serves the copy to
7493
+ // whoever asks next — cookie or not. Proven live: a `hop view` PDF
7494
+ // fetched once WITH auth came back cf-cache-status: HIT without it.
7495
+ // Enforced here, at the writeHead choke point, so proxied upstream
7496
+ // headers cannot reopen the hole; a route with a deliberately
7497
+ // different policy sets res.hopCacheControl before responding.
7498
+ const rawWriteHead = res.writeHead.bind(res);
7499
+ res.writeHead = (...args) => {
7500
+ for (const a of args) {
7501
+ if (a && typeof a === 'object' && !Array.isArray(a)) {
7502
+ for (const k of Object.keys(a)) {
7503
+ if (String(k).toLowerCase() === 'cache-control') delete a[k];
7504
+ }
7505
+ }
7506
+ }
7507
+ res.setHeader('Cache-Control', res.hopCacheControl || 'private, no-store');
7508
+ return rawWriteHead(...args);
7509
+ };
7510
+
7427
7511
  // Check auth via cookie OR Authorization header (internal hop-to-hop only)
7428
7512
  // Bearer token is read from state file by other hop processes on same machine
7429
7513
  const authHeader = req.headers.authorization;
@@ -7453,6 +7537,18 @@ function startAuthProxy(authEmitter) {
7453
7537
  return;
7454
7538
  }
7455
7539
 
7540
+ // Apple App Site Association — the trust anchor for NATIVE passkey
7541
+ // sign-in from hop-ios (webcredentials). Apple's CDN fetches this
7542
+ // anonymously over HTTPS, so it must sit in front of the auth gate.
7543
+ // Team + bundle are hop-ios's (project.yml); a WKWebView can never run
7544
+ // WebAuthn, so without this file the phone has no passkey path at all.
7545
+ if (parsedUrl.pathname === '/.well-known/apple-app-site-association' && req.method === 'GET') {
7546
+ res.hopCacheControl = 'public, max-age=300'; // public on purpose — Apple's CDN reads it
7547
+ res.writeHead(200, { 'Content-Type': 'application/json' });
7548
+ res.end(JSON.stringify({ webcredentials: { apps: ['5AD7QB9795.io.zhoulab.hop.spike'] } }));
7549
+ return;
7550
+ }
7551
+
7456
7552
  // --- PASSKEYS (WebAuthn) ---
7457
7553
  if (parsedUrl.pathname.startsWith('/api/passkeys/') && req.method === 'POST') {
7458
7554
  const respond = (status, payload) => {
@@ -7957,6 +8053,42 @@ function startAuthProxy(authEmitter) {
7957
8053
  return;
7958
8054
  }
7959
8055
 
8056
+ // ---- Views files --------------------------------------------------
8057
+ // Served on an EXTENSIONLESS path on purpose: the edge cache stores
8058
+ // by URL extension and can override origin cache directives, and a
8059
+ // stored copy answers cookie-less clients — observed live with a
8060
+ // `hop view` PDF. The /inline terminal segment keeps the extension
8061
+ // off the end of the path; content-type still comes from the real
8062
+ // file name. The legacy /assets/view/<s>/<f> form redirects here, so
8063
+ // an edge-cached response is at worst a redirect, never the bytes.
8064
+ const viewMatch = req.method === 'GET'
8065
+ && parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline)?$/);
8066
+ if (viewMatch) {
8067
+ const viewRoot = path.resolve(path.join(HAY_WEB_DIR, 'assets', 'view'));
8068
+ const target = path.resolve(path.join(
8069
+ viewRoot, decodeURIComponent(viewMatch[1]), decodeURIComponent(viewMatch[2])));
8070
+ if (!target.startsWith(viewRoot + path.sep)
8071
+ || !fs.existsSync(target) || !fs.statSync(target).isFile()) {
8072
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
8073
+ res.end('Not found');
8074
+ return;
8075
+ }
8076
+ res.writeHead(200, {
8077
+ 'Content-Type': STATIC_CONTENT_TYPES[path.extname(target).toLowerCase()] || 'application/octet-stream'
8078
+ });
8079
+ res.end(fs.readFileSync(target));
8080
+ return;
8081
+ }
8082
+ if (req.method === 'GET' && parsedUrl.pathname.startsWith('/assets/view/')) {
8083
+ const segs = parsedUrl.pathname.slice('/assets/view/'.length).split('/');
8084
+ if (segs.length === 2 && segs[1] && segs[1] !== 'manifest.json') {
8085
+ res.writeHead(302, { Location: `/view/${segs[0]}/${segs[1]}/inline` });
8086
+ res.end();
8087
+ return;
8088
+ }
8089
+ // manifest.json falls through to plain static serving
8090
+ }
8091
+
7960
8092
  if (sessionInfoAuth && sessionInfoAuth.source === 'query' && shouldRedirectToSessionPath(req)) {
7961
8093
  res.writeHead(302, { 'Location': buildSessionPath(displayName) });
7962
8094
  res.end();
@@ -9423,7 +9555,7 @@ function startAuthProxy(authEmitter) {
9423
9555
  const terminalStreamMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)\/stream$/);
9424
9556
  if (terminalStreamMatch && req.method === 'GET') {
9425
9557
  const terminalId = terminalStreamMatch[1];
9426
- const entry = terminalApiSessions.get(terminalId);
9558
+ const entry = getTerminalApiEntryTouched(terminalId);
9427
9559
  if (!entry) {
9428
9560
  res.writeHead(404, { 'Content-Type': 'application/json' });
9429
9561
  res.end(JSON.stringify({ error: 'Terminal not found' }));
@@ -9459,7 +9591,7 @@ function startAuthProxy(authEmitter) {
9459
9591
  const terminalWriteMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)\/write$/);
9460
9592
  if (terminalWriteMatch && req.method === 'POST') {
9461
9593
  const terminalId = terminalWriteMatch[1];
9462
- const entry = terminalApiSessions.get(terminalId);
9594
+ const entry = getTerminalApiEntryTouched(terminalId);
9463
9595
  if (!entry) {
9464
9596
  res.writeHead(404, { 'Content-Type': 'application/json' });
9465
9597
  res.end(JSON.stringify({ error: 'Terminal not found' }));
@@ -9498,7 +9630,7 @@ function startAuthProxy(authEmitter) {
9498
9630
  const terminalResizeMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)\/resize$/);
9499
9631
  if (terminalResizeMatch && req.method === 'POST') {
9500
9632
  const terminalId = terminalResizeMatch[1];
9501
- const entry = terminalApiSessions.get(terminalId);
9633
+ const entry = getTerminalApiEntryTouched(terminalId);
9502
9634
  if (!entry) {
9503
9635
  res.writeHead(404, { 'Content-Type': 'application/json' });
9504
9636
  res.end(JSON.stringify({ error: 'Terminal not found' }));
@@ -9531,7 +9663,7 @@ function startAuthProxy(authEmitter) {
9531
9663
  const terminalExecMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)\/exec$/);
9532
9664
  if (terminalExecMatch && req.method === 'POST') {
9533
9665
  const terminalId = terminalExecMatch[1];
9534
- const entry = terminalApiSessions.get(terminalId);
9666
+ const entry = getTerminalApiEntryTouched(terminalId);
9535
9667
  if (!entry) {
9536
9668
  res.writeHead(404, { 'Content-Type': 'application/json' });
9537
9669
  res.end(JSON.stringify({ error: 'Terminal not found' }));
@@ -9571,7 +9703,7 @@ function startAuthProxy(authEmitter) {
9571
9703
  const terminalDeleteMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)$/);
9572
9704
  if (terminalDeleteMatch && req.method === 'DELETE') {
9573
9705
  const terminalId = terminalDeleteMatch[1];
9574
- const entry = terminalApiSessions.get(terminalId);
9706
+ const entry = getTerminalApiEntryTouched(terminalId);
9575
9707
  if (!entry) {
9576
9708
  res.writeHead(404, { 'Content-Type': 'application/json' });
9577
9709
  res.end(JSON.stringify({ error: 'Terminal not found' }));
@@ -9795,16 +9927,13 @@ function startAuthProxy(authEmitter) {
9795
9927
  return;
9796
9928
  }
9797
9929
  const hubExt = path.extname(resolvedHub).toLowerCase();
9798
- const hubTypes = {
9799
- '.html': 'text/html; charset=utf-8',
9800
- '.js': 'application/javascript',
9801
- '.css': 'text/css',
9802
- '.json': 'application/json',
9803
- '.png': 'image/png',
9804
- '.jpg': 'image/jpeg',
9805
- '.svg': 'image/svg+xml',
9806
- '.ico': 'image/x-icon'
9807
- };
9930
+ const hubTypes = STATIC_CONTENT_TYPES;
9931
+ // Hashed bundle files may live in the BROWSER's cache for a while
9932
+ // ('private' keeps every shared cache out); html and Views files
9933
+ // stay on the no-store default.
9934
+ if (hubExt !== '.html' && !parsedUrl.pathname.startsWith('/assets/view/')) {
9935
+ res.hopCacheControl = 'private, max-age=3600';
9936
+ }
9808
9937
  let hubContent = fs.readFileSync(resolvedHub);
9809
9938
  if (hubExt === '.html') {
9810
9939
  // Room-less config: marks the page as hop-served (hub mode).
@@ -9952,16 +10081,12 @@ function startAuthProxy(authEmitter) {
9952
10081
 
9953
10082
  // Determine content type
9954
10083
  const ext = path.extname(filePath).toLowerCase();
9955
- const contentTypes = {
9956
- '.html': 'text/html; charset=utf-8',
9957
- '.js': 'application/javascript',
9958
- '.css': 'text/css',
9959
- '.json': 'application/json',
9960
- '.png': 'image/png',
9961
- '.jpg': 'image/jpeg',
9962
- '.svg': 'image/svg+xml',
9963
- '.ico': 'image/x-icon'
9964
- };
10084
+ // Same split as hub scope: hashed bundles browser-cache privately,
10085
+ // html and Views files keep the no-store default.
10086
+ if (ext !== '.html' && !reqPath.startsWith('/assets/view/')) {
10087
+ res.hopCacheControl = 'private, max-age=3600';
10088
+ }
10089
+ const contentTypes = STATIC_CONTENT_TYPES;
9965
10090
  const contentType = contentTypes[ext] || 'application/octet-stream';
9966
10091
 
9967
10092
  // Read and serve the file
@@ -10154,6 +10279,28 @@ function startAuthProxy(authEmitter) {
10154
10279
  const rows = parseInt(wsUrl.searchParams.get('rows') || '24', 10);
10155
10280
  const source = wsUrl.searchParams.get('source') || '';
10156
10281
  const claimId = wsUrl.searchParams.get('claim') || null;
10282
+ // Client-supplied tab identity; the room evicts a same-key
10283
+ // predecessor on attach (reconnect replaces its own ghost).
10284
+ const deviceKeyRaw = wsUrl.searchParams.get('device') || '';
10285
+ const deviceKey = /^[A-Za-z0-9._-]{1,80}$/.test(deviceKeyRaw) ? deviceKeyRaw : '';
10286
+
10287
+ // Liveness: browsers answer protocol pings in the network stack,
10288
+ // so silence means the peer is gone (locked phone, network hop)
10289
+ // even though no close frame ever arrived. Without this, ghost
10290
+ // clients sat in presence until the tunnel's TCP gave up.
10291
+ let wsAlive = true;
10292
+ ws.on('pong', () => { wsAlive = true; });
10293
+ const wsHeartbeat = setInterval(() => {
10294
+ if (ws.readyState !== WebSocket.OPEN) { clearInterval(wsHeartbeat); return; }
10295
+ if (!wsAlive) {
10296
+ clearInterval(wsHeartbeat);
10297
+ try { ws.terminate(); } catch (e) { /* already gone */ }
10298
+ return;
10299
+ }
10300
+ wsAlive = false;
10301
+ try { ws.ping(); } catch (e) { /* closing */ }
10302
+ }, 30000);
10303
+ ws.on('close', () => clearInterval(wsHeartbeat));
10157
10304
 
10158
10305
  const clientId = hay.generateClientId();
10159
10306
  const colorIndex = Math.floor(Math.random() * 1000);
@@ -10173,7 +10320,7 @@ function startAuthProxy(authEmitter) {
10173
10320
 
10174
10321
  // Attach client to hay room
10175
10322
  session.room.attachClient(
10176
- { id: clientId, name: clientName, source, colorIndex, cols, rows },
10323
+ { id: clientId, name: clientName, source, deviceKey, colorIndex, cols, rows },
10177
10324
  hay.createSocketAdapter(ws)
10178
10325
  );
10179
10326
  });
@@ -12785,41 +12932,51 @@ async function main() {
12785
12932
  process.exit(1);
12786
12933
  }
12787
12934
  }
12788
- if (local === upstream) {
12935
+ // --restart: cycle onto the CURRENT checkout even when git has
12936
+ // nothing to pull. The running processes can predate the checkout —
12937
+ // commit locally, push, `hop update`, and "Already up to date" used
12938
+ // to leave every process on pre-commit code with no hint.
12939
+ const restartOnly = process.argv.includes('--restart');
12940
+ if (local === upstream && (checkOnly || !restartOnly)) {
12789
12941
  log(`Already up to date (${local.slice(0, 7)}).`, GREEN);
12942
+ if (!checkOnly) log(`Processes were not cycled — if they predate this checkout, ${CYAN}hop update --restart${RESET} restarts them onto it.`, RESET);
12790
12943
  process.exit(0);
12791
12944
  }
12792
- const behind = runGit(`git rev-list --count HEAD..${upstream}`);
12793
- const ahead = runGit(`git rev-list --count ${upstream}..HEAD`);
12794
- log(`Update available: ${behind} new commit(s)${ahead !== '0' ? ` — but local is ${ahead} ahead; resolve manually` : ''}`, YELLOW);
12795
- try { log(runGit(`git log --oneline --no-decorate HEAD..${upstream} -15`), RESET); } catch (e) { }
12796
- if (checkOnly) process.exit(0);
12797
- if (ahead !== '0') process.exit(1);
12798
- let dirty = '';
12799
- try { dirty = runGit('git status --porcelain'); } catch (e) { }
12800
- if (dirty) {
12801
- log('Working tree has local changes — commit or stash them first.', RED);
12802
- process.exit(1);
12803
- }
12804
- // Dependency fingerprint across the pull: reinstall only when a
12805
- // lockfile actually changed.
12806
- const lockRev = (ref, file) => { try { return runGit(`git rev-parse ${ref}:${file}`); } catch (e) { return ''; } };
12807
- const locks = ['package-lock.json', 'hay/package-lock.json'];
12808
- const before = locks.map((f) => lockRev('HEAD', f));
12809
- try { runGit(`git merge --ff-only ${upstream}`); } catch (e) {
12810
- log(`Fast-forward failed: ${e.message}`, RED);
12811
- process.exit(1);
12812
- }
12813
- const after = locks.map((f) => lockRev('HEAD', f));
12814
- for (let i = 0; i < locks.length; i++) {
12815
- if (before[i] !== after[i]) {
12816
- const dir = locks[i].startsWith('hay/') ? path.join(__dirname, 'hay') : __dirname;
12817
- log(`Dependencies changed (${locks[i]}) — installing…`, CYAN);
12818
- try {
12819
- execSync('npm install --no-audit --no-fund', { cwd: dir, stdio: 'inherit', timeout: 600000 });
12820
- } catch (e) {
12821
- log(`npm install failed in ${dir}: ${e.message}`, RED);
12822
- process.exit(1);
12945
+ if (local === upstream) {
12946
+ log(`Checkout already current (${local.slice(0, 7)}) — restarting processes onto it.`, CYAN);
12947
+ } else {
12948
+ const behind = runGit(`git rev-list --count HEAD..${upstream}`);
12949
+ const ahead = runGit(`git rev-list --count ${upstream}..HEAD`);
12950
+ log(`Update available: ${behind} new commit(s)${ahead !== '0' ? ` — but local is ${ahead} ahead; resolve manually` : ''}`, YELLOW);
12951
+ try { log(runGit(`git log --oneline --no-decorate HEAD..${upstream} -15`), RESET); } catch (e) { }
12952
+ if (checkOnly) process.exit(0);
12953
+ if (ahead !== '0') process.exit(1);
12954
+ let dirty = '';
12955
+ try { dirty = runGit('git status --porcelain'); } catch (e) { }
12956
+ if (dirty) {
12957
+ log('Working tree has local changes — commit or stash them first.', RED);
12958
+ process.exit(1);
12959
+ }
12960
+ // Dependency fingerprint across the pull: reinstall only when a
12961
+ // lockfile actually changed.
12962
+ const lockRev = (ref, file) => { try { return runGit(`git rev-parse ${ref}:${file}`); } catch (e) { return ''; } };
12963
+ const locks = ['package-lock.json', 'hay/package-lock.json'];
12964
+ const before = locks.map((f) => lockRev('HEAD', f));
12965
+ try { runGit(`git merge --ff-only ${upstream}`); } catch (e) {
12966
+ log(`Fast-forward failed: ${e.message}`, RED);
12967
+ process.exit(1);
12968
+ }
12969
+ const after = locks.map((f) => lockRev('HEAD', f));
12970
+ for (let i = 0; i < locks.length; i++) {
12971
+ if (before[i] !== after[i]) {
12972
+ const dir = locks[i].startsWith('hay/') ? path.join(__dirname, 'hay') : __dirname;
12973
+ log(`Dependencies changed (${locks[i]}) — installing…`, CYAN);
12974
+ try {
12975
+ execSync('npm install --no-audit --no-fund', { cwd: dir, stdio: 'inherit', timeout: 600000 });
12976
+ } catch (e) {
12977
+ log(`npm install failed in ${dir}: ${e.message}`, RED);
12978
+ process.exit(1);
12979
+ }
12823
12980
  }
12824
12981
  }
12825
12982
  }
@@ -13162,7 +13319,9 @@ async function main() {
13162
13319
  entries.push({
13163
13320
  session: sess,
13164
13321
  name: f,
13165
- path: `/assets/view/${encodeURIComponent(sess)}/${encodeURIComponent(f)}`,
13322
+ // The extensionless serving path — see the /view route
13323
+ // for why the extension must stay off the URL's end.
13324
+ path: `/view/${encodeURIComponent(sess)}/${encodeURIComponent(f)}/inline`,
13166
13325
  bytes: st.size,
13167
13326
  mtime: Math.floor(st.mtimeMs / 1000)
13168
13327
  });
@@ -13204,7 +13363,7 @@ async function main() {
13204
13363
  // session subdir already namespaces collisions between agents.
13205
13364
  const dest = path.join(destDir, path.basename(src));
13206
13365
  fs.copyFileSync(src, dest);
13207
- console.log(`View: ${state.url}/assets/view/${encodeURIComponent(session)}/${encodeURIComponent(path.basename(src))}`);
13366
+ console.log(`View: ${state.url}/view/${encodeURIComponent(session)}/${encodeURIComponent(path.basename(src))}/inline`);
13208
13367
  }
13209
13368
  if (failed === 0 && files.length > 0) {
13210
13369
  // One bell: the artifact is usually the deliverable the human is
@@ -13274,7 +13433,7 @@ async function main() {
13274
13433
  ].join(''));
13275
13434
  rebuildArtifactManifest();
13276
13435
  console.log(`View: ${proxyUrl}`);
13277
- console.log(`Attached to ${caller} — also in its artifacts.`);
13436
+ console.log(`Attached to ${caller} — also in its Views.`);
13278
13437
  } else {
13279
13438
  console.log(`View: ${proxyUrl}`);
13280
13439
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.12",
3
+ "version": "0.11.13",
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": {
@@ -393,11 +393,36 @@ async function main() {
393
393
  if (clientBg && typeof room.setClientColors === 'function') {
394
394
  room.setClientColors(clientBg, clientFg);
395
395
  }
396
+ // Tab identity forwarded by the daemon: the room evicts a same-key
397
+ // predecessor on attach, so a phone's reconnect replaces its own
398
+ // ghost in presence instead of standing next to it.
399
+ const deviceKeyRaw = wsUrl.searchParams.get('device') || '';
400
+ const deviceKey = /^[A-Za-z0-9._-]{1,80}$/.test(deviceKeyRaw) ? deviceKeyRaw : '';
401
+
402
+ // Liveness: browsers answer protocol pings in the network stack, so
403
+ // silence means the peer is gone (locked phone, network hop) even
404
+ // though no close frame ever arrived — through the tunnel that can
405
+ // outlive the client by hours. Ghosts die within a minute instead.
406
+ let wsAlive = true;
407
+ ws.on('pong', () => { wsAlive = true; });
408
+ const wsHeartbeat = setInterval(() => {
409
+ if (ws.readyState !== ws.OPEN) { clearInterval(wsHeartbeat); return; }
410
+ if (!wsAlive) {
411
+ clearInterval(wsHeartbeat);
412
+ try { ws.terminate(); } catch (e) { /* already gone */ }
413
+ return;
414
+ }
415
+ wsAlive = false;
416
+ try { ws.ping(); } catch (e) { /* closing */ }
417
+ }, 30000);
418
+ ws.on('close', () => clearInterval(wsHeartbeat));
419
+
396
420
  room.attachClient(
397
421
  {
398
422
  id: randomUUID(),
399
423
  name,
400
424
  source,
425
+ deviceKey,
401
426
  colorIndex: Math.floor(Math.random() * 1000),
402
427
  cols,
403
428
  rows,
@@ -37,7 +37,7 @@ hop view report.html analysis.pdf
37
37
  `hop view` copies the file behind hop's auth and prints a `View:` URL. The
38
38
  human taps it and their browser renders it — on the desktop wall or from the
39
39
  phone app, which opens these in-app. It rings the bell once for you, since a
40
- published artifact is usually the thing the human is waiting on. Re-running
40
+ published file is usually the thing the human is waiting on. Re-running
41
41
  it after updating the file re-publishes under the same link.
42
42
 
43
43
  ## Showing a running web server (dev server, local app)
@@ -688,3 +688,49 @@ test('bare token API creations default to agent origin', async () => {
688
688
  await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'BareTokenProbe', internalName: 'BareTokenProbe' });
689
689
  await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CliUserProbe', internalName: 'CliUserProbe' });
690
690
  });
691
+
692
+ // An agent attach with no API activity and no open stream must expire — a
693
+ // crashed or forgetful agent otherwise parks an "agent" in the session's
694
+ // presence forever. Needs a daemon with a short TTL, so this test relaunches
695
+ // (declaration order puts it last; nothing later depends on daemon state).
696
+ test('an idle agent terminal attach is reaped after the TTL', async () => {
697
+ const previousState = state;
698
+ daemonEnv = {
699
+ ...daemonEnv,
700
+ HOP_TERMINAL_API_IDLE_TTL_MS: '1500',
701
+ HOP_TERMINAL_API_SWEEP_MS: '300'
702
+ };
703
+ await stopDaemon(true);
704
+ await launchDaemon(previousState);
705
+
706
+ const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/terminals', {
707
+ name: 'idle-agent-probe',
708
+ cwd: tempDir
709
+ }, agentHeaders);
710
+ assert.equal(create.status, 200);
711
+ const terminalId = create.data.id;
712
+
713
+ const listed = await requestJson(state.port, state.sessionSecret, 'GET', '/api/terminals', null, agentHeaders);
714
+ assert.ok(listed.data.terminals.some(t => t.id === terminalId), 'attach visible before the TTL');
715
+
716
+ // Touches reset the clock: keep it alive past one full TTL with writes.
717
+ await delay(1000);
718
+ await requestJson(state.port, state.sessionSecret, 'POST', `/api/terminals/${terminalId}/write`,
719
+ { data: ' ' }, agentHeaders);
720
+ await delay(1000);
721
+ const alive = await requestJson(state.port, state.sessionSecret, 'GET', '/api/terminals', null, agentHeaders);
722
+ assert.ok(alive.data.terminals.some(t => t.id === terminalId), 'touched attach survives past a TTL of idle-from-create');
723
+
724
+ // Now go silent: the sweep must detach it (session itself stays alive).
725
+ await delay(2500);
726
+ const after = await requestJson(state.port, state.sessionSecret, 'GET', '/api/terminals', null, agentHeaders);
727
+ assert.ok(!after.data.terminals.some(t => t.id === terminalId), 'idle attach reaped');
728
+ const sessions = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
729
+ const s = sessions.data.sessions.find(x => x.displayName === 'idle-agent-probe');
730
+ assert.ok(s, 'the session itself outlives its reaped attach');
731
+ const clients = Number(s.clientCount) || 0;
732
+ assert.equal(clients, 0, `reap must detach the room client, got clientCount=${clients}`);
733
+
734
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
735
+ { name: 'idle-agent-probe', internalName: s.internalName });
736
+ });