@mmmbuto/nexuscrew 0.9.39 → 0.9.41

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  const http = require('node:http');
4
4
  const net = require('node:net');
5
+ const crypto = require('node:crypto');
5
6
  const express = require('express');
6
7
  const { WebSocketServer } = require('ws');
7
8
  const store = require('../nodes/store.js');
@@ -1497,7 +1498,7 @@ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecre
1497
1498
  return r;
1498
1499
  }
1499
1500
 
1500
- function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null }) {
1501
+ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null, hubAttaches = null }) {
1501
1502
  if (readonly()) return reject(socket, 403);
1502
1503
  const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
1503
1504
  // Due percorsi separati: questo NON passa da routeHandler e non ne condivide i
@@ -1516,6 +1517,16 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1516
1517
  if (!st) return reject(socket, 503);
1517
1518
  const visited = controlledVisited(req, ingress, st.nodeId);
1518
1519
  if (!visited) return reject(socket, 409);
1520
+ // L'id e la sessione viaggiano SOLO nella query (niente de-mascheramento dei
1521
+ // frame); il token si confronta per IMPRONTA e non entra mai in chiaro.
1522
+ const attachId = parseAttachId(queryParam(req.url, 'attachId'));
1523
+ const attachSession = parseAttachSession(queryParam(req.url, 'attachSession'));
1524
+ // Sul percorso del browser il token e' in query; su quello FEDERATO e' il
1525
+ // Bearer del peer che ha originato l'attach (la query non lo porta). Senza
1526
+ // questo, l'ingresso federato non entrava mai nel ramo trattenuto.
1527
+ const attachToken = queryParam(req.url, 'token') || bearerFrom(req) || null;
1528
+ const attachRoute = parsed.route.join('/');
1529
+ const hubMode = Boolean(hubAttaches && attachId && attachSession && attachToken);
1519
1530
  let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential();
1520
1531
  // Ultimo hop: `/ws` entra com'e', il pannello rientra nell'API locale.
1521
1532
  let path = parsed.resource === '/ws' ? '/ws' : `/api${parsed.resource}`;
@@ -1527,6 +1538,43 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1527
1538
  const rest = parsed.route.slice(1);
1528
1539
  path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}`;
1529
1540
  }
1541
+ // Aggancio alla attach trattenuta — SOLO dopo i gate d'ingresso appena
1542
+ // eseguiti (route/next/privateInbound/canTransit) e SOLO se il record e'
1543
+ // STACCATO: un secondo upgrade con la stessa identita' non deve rubare lo
1544
+ // stream a un client ancora connesso.
1545
+ if (hubMode) {
1546
+ const record = hubAttaches.take({
1547
+ attachId, tokenHash: tokenFingerprint(attachToken), route: attachRoute, session: attachSession,
1548
+ });
1549
+ if (record) {
1550
+ const handshake = wsHandshakeResponse(req);
1551
+ if (!handshake) return reject(socket, 403);
1552
+ // 101 a mano, poi passthrough: l'input del nuovo client passa com'e' e
1553
+ // l'upstream de-maschera per frame (la chiave di mascheramento puo'
1554
+ // cambiare), l'output va scritto grezzo (i frame li ha gia' incorniciati
1555
+ // l'upstream).
1556
+ try { socket.write(handshake); } catch (_) { /* morto */ }
1557
+ // Gli eventuali byte gia' arrivati dopo la richiesta di upgrade vanno
1558
+ // inoltrati come nel ramo normale, non persi.
1559
+ if (head && head.length) { try { record.up.write(head); } catch (_) { /* morto */ } }
1560
+ socket.pipe(record.up, { end: false });
1561
+ // Identity check: la caduta di QUESTO socket non deve staccare il record
1562
+ // se nel frattempo ne ha preso uno nuovo.
1563
+ const onGoneResume = () => { if (record.socket === socket) hubAttaches.detach(record); };
1564
+ socket.on('end', onGoneResume);
1565
+ socket.on('close', onGoneResume);
1566
+ socket.on('error', () => { /* il close segue */ });
1567
+ // Anche questo socket entra in activeSockets, o la chiusura ordinata
1568
+ // del server non lo vede.
1569
+ if (activeSockets && typeof activeSockets.add === 'function') {
1570
+ activeSockets.add(socket); activeSockets.add(record.up);
1571
+ const remove = () => { activeSockets.delete(socket); activeSockets.delete(record.up); };
1572
+ socket.once('close', remove); record.up.once('close', remove);
1573
+ }
1574
+ hubAttaches.attach(record, socket);
1575
+ return;
1576
+ }
1577
+ }
1530
1578
  const up = net.connect({ host: '127.0.0.1', port });
1531
1579
  // Osservatore NON consumante della testa di risposta dell'upstream: serve
1532
1580
  // solo a sapere se il 101 e' transitato (client in stato WebSocket) o se
@@ -1581,14 +1629,48 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1581
1629
  const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
1582
1630
  socket.once('close', remove); up.once('close', remove);
1583
1631
  }
1632
+ if (hubMode) {
1633
+ // Attach trattenuta: l'output passa dal ring quando il client non c'è,
1634
+ // e il record entra nello store sotto la sua identità.
1635
+ const record = hubAttaches.hold({
1636
+ attachId, tokenHash: tokenFingerprint(attachToken), route: attachRoute, session: attachSession, up,
1637
+ });
1638
+ up.on('data', (chunk) => hubAttaches.feed(record, chunk));
1639
+ up.on('close', () => hubAttaches.destroy(record));
1640
+ up.on('error', () => hubAttaches.destroy(record));
1641
+ // end:false: la caduta del CLIENT non deve chiudere l'upstream — è
1642
+ // esattamente ciò che la grazia deve trattenere.
1643
+ socket.pipe(up, { end: false });
1644
+ hubAttaches.attach(record, socket);
1645
+ // Senza un handler d'errore il RST del client morto resta senza 'close':
1646
+ // la caduta non verrebbe mai rilevata e la grazia non partirebbe.
1647
+ socket.on('error', () => { /* il close segue */ });
1648
+ // 'end' E 'close': con la pipe a end:false il socket resta HALF-OPEN
1649
+ // quando il browser se ne va, quindi il solo 'close' puo' non arrivare
1650
+ // mai e la grazia non partirebbe. Il FIN in lettura e' la caduta.
1651
+ const onGoneHeld = () => { if (record.socket === socket) hubAttaches.detach(record); };
1652
+ socket.on('end', onGoneHeld);
1653
+ socket.on('close', onGoneHeld);
1654
+ if (activeSockets && typeof activeSockets.add === 'function') {
1655
+ activeSockets.add(socket); activeSockets.add(up);
1656
+ const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
1657
+ socket.once('close', remove); up.once('close', remove);
1658
+ }
1659
+ return;
1660
+ }
1584
1661
  socket.pipe(up);
1585
1662
  // end:false: la chiusura verso il browser la governiamo noi, o la pipe
1586
1663
  // chiuderebbe il socket PRIMA che il frame di close 4402 possa partire.
1587
1664
  up.pipe(socket, { end: false });
1588
1665
  });
1589
- up.on('error', () => upstreamGone());
1590
- up.on('end', () => upstreamGone());
1591
- up.on('close', () => upstreamGone());
1666
+ if (!hubMode) {
1667
+ // Nel ramo trattenuto la morte dell'upstream la governa lo store (destroy
1668
+ // del record): qui il close leggibile verso il browser non ha più un
1669
+ // destinatario quando il client è già caduto.
1670
+ up.on('error', () => upstreamGone());
1671
+ up.on('end', () => upstreamGone());
1672
+ up.on('close', () => upstreamGone());
1673
+ }
1592
1674
  socket.on('error', () => up.destroy());
1593
1675
  }
1594
1676
 
@@ -1610,6 +1692,181 @@ function rejectAsNoSuchSession(socket, req) {
1610
1692
  } catch (_) { try { socket.destroy(); } catch (_) { /* idem */ } }
1611
1693
  }
1612
1694
 
1695
+ // ---- attach trattenuta nell'hub --------------------------------------
1696
+ // Quando il client cade, l'upstream federato NON viene distrutto subito: resta
1697
+ // vivo per la grazia e il suo output va in un ring. Chi torna con la STESSA
1698
+ // identità (hash del token, stessa route, stessa sessione) riprende da qui
1699
+ // senza riaprire l'attach; chiunque altro fa una attach nuova.
1700
+ const HUB_ATTACH_MAX = 8;
1701
+ const HUB_ATTACH_GRACE_MS = 60000;
1702
+ const HUB_ATTACH_RING_BYTES = 256 * 1024;
1703
+
1704
+ function queryParam(url, name) {
1705
+ const q = String(url || '').split('?')[1];
1706
+ if (!q) return null;
1707
+ for (const part of q.split('&')) {
1708
+ const i = part.indexOf('=');
1709
+ if (i <= 0) continue;
1710
+ let key = part.slice(0, i);
1711
+ try { key = decodeURIComponent(key); } catch (_) { /* chiave grezza */ }
1712
+ if (key !== name) continue;
1713
+ try { return decodeURIComponent(part.slice(i + 1)); } catch (_) { return part.slice(i + 1); }
1714
+ }
1715
+ return null;
1716
+ }
1717
+ // 128 bit in esadecimale, e nient'altro: un id malformato non è un attachId.
1718
+ const ATTACH_ID_RE = /^[0-9a-f]{32}$/;
1719
+ const parseAttachId = (raw) => (typeof raw === 'string' && ATTACH_ID_RE.test(raw) ? raw : null);
1720
+ // Nome sessione con lo stesso alfabeto accettato dalle risorse federate.
1721
+ const ATTACH_SESSION_RE = /^[\w.@%:+-]{1,128}$/;
1722
+ const parseAttachSession = (raw) => (typeof raw === 'string' && ATTACH_SESSION_RE.test(raw) ? raw : null);
1723
+ // Il token non entra MAI in memoria in chiaro: si confronta la sua impronta.
1724
+ const tokenFingerprint = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
1725
+ // Diagnostica: mai l'id completo, mai il token.
1726
+ const shortId = (id) => (typeof id === 'string' ? id.slice(0, 6) : '-');
1727
+
1728
+ // Ricompone i confini di frame del protocollo WS leggendo SOLO la lunghezza
1729
+ // dall'header. Server->client non e' mascherato; se lo fosse, l'header e' piu'
1730
+ // lungo e i 4 byte di maschera si saltano senza toccare il payload.
1731
+ function createFrameSplitter() {
1732
+ let buf = Buffer.alloc(0);
1733
+ return {
1734
+ push(chunk) {
1735
+ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
1736
+ const frames = [];
1737
+ let off = 0;
1738
+ while (off + 2 <= buf.length) {
1739
+ const b1 = buf[off + 1];
1740
+ let len = b1 & 0x7f;
1741
+ let head = 2;
1742
+ if (len === 126) {
1743
+ if (off + 4 > buf.length) break;
1744
+ len = buf.readUInt16BE(off + 2); head = 4;
1745
+ } else if (len === 127) {
1746
+ if (off + 10 > buf.length) break;
1747
+ len = Number(buf.readBigUInt64BE(off + 2)); head = 10;
1748
+ }
1749
+ if (b1 & 0x80) head += 4;
1750
+ const total = head + len;
1751
+ if (off + total > buf.length) break;
1752
+ frames.push(buf.subarray(off, off + total));
1753
+ off += total;
1754
+ }
1755
+ buf = off ? buf.subarray(off) : buf;
1756
+ return frames;
1757
+ },
1758
+ pending: () => buf,
1759
+ };
1760
+ }
1761
+
1762
+ function createHubAttachStore({ graceMs = HUB_ATTACH_GRACE_MS, maxAttaches = HUB_ATTACH_MAX, ringBytes = HUB_ATTACH_RING_BYTES } = {}) {
1763
+ const grace = Math.max(1, Number(graceMs) || HUB_ATTACH_GRACE_MS);
1764
+ const cap = Math.max(1, Math.floor(Number(maxAttaches) || HUB_ATTACH_MAX));
1765
+ const ringLimit = Math.max(1, Math.floor(Number(ringBytes) || HUB_ATTACH_RING_BYTES));
1766
+ const records = new Map();
1767
+
1768
+ const destroy = (record) => {
1769
+ if (!record || record.ended) return;
1770
+ record.ended = true;
1771
+ if (record.timer) { clearTimeout(record.timer); record.timer = null; }
1772
+ if (records.get(record.attachId) === record) records.delete(record.attachId);
1773
+ // Se un client e' ATTACCATO, la morte dell'upstream va DETTA subito: senza,
1774
+ // il browser resta appeso finche' la liveness non scade (10 s) — e in
1775
+ // 0.9.39 il 4402 arrivava invece immediatamente. Se nessuno e' attaccato
1776
+ // non c'e' nulla da avvisare.
1777
+ if (record.socket && record.socket.writable) {
1778
+ try { record.socket.write(wsCloseFrame(4402, 'upstream gone')); } catch (_) { /* morto */ }
1779
+ try { record.socket.end(); } catch (_) { /* idem */ }
1780
+ record.socket = null;
1781
+ }
1782
+ try { record.up.destroy(); } catch (_) { /* già chiuso */ }
1783
+ };
1784
+ // Il ring tiene FRAME INTERI: un ring di soli chunk TCP puo' consegnare un
1785
+ // replay che comincia a meta' frame, e il browser chiude con 1002. Il parser
1786
+ // legge SOLO la lunghezza dagli header (server->client non maschera) e non
1787
+ // tocca i payload: nessun de-mascheramento.
1788
+ const pushRing = (record, frame) => {
1789
+ record.ring.push(frame);
1790
+ record.ringBytes += frame.length;
1791
+ while (record.ringBytes > ringLimit && record.ring.length > 1) record.ringBytes -= record.ring.shift().length;
1792
+ };
1793
+ const hold = ({ attachId, tokenHash, route, session, up }) => {
1794
+ // Cap: si chiude la PIÙ VECCHIA, non si rifiuta la nuova.
1795
+ while (records.size >= cap) {
1796
+ const oldest = records.keys().next().value;
1797
+ const rec = oldest ? records.get(oldest) : null;
1798
+ if (!rec) break;
1799
+ destroy(rec);
1800
+ }
1801
+ const record = {
1802
+ attachId, tokenHash, route, session, up, socket: null,
1803
+ ring: [], ringBytes: 0, timer: null, ended: false, splitter: createFrameSplitter(),
1804
+ };
1805
+ records.set(attachId, record);
1806
+ return record;
1807
+ };
1808
+ // Scrive il pregresso al nuovo client: prima il ring, poi lo stream live.
1809
+ const attach = (record, socket) => {
1810
+ if (!record || record.ended) return false;
1811
+ if (record.timer) { clearTimeout(record.timer); record.timer = null; }
1812
+ record.socket = socket;
1813
+ for (const frame of record.ring) { try { socket.write(frame); } catch (_) { break; } }
1814
+ // I byte già letti ma non ancora completati in un frame vanno consegnati
1815
+ // ora: senza, il client perderebbe la coda dell'ultimo frame.
1816
+ const pending = record.splitter.pending();
1817
+ if (pending && pending.length) { try { socket.write(pending); } catch (_) { /* morto */ } }
1818
+ record.splitter = createFrameSplitter();
1819
+ record.ring = []; record.ringBytes = 0;
1820
+ return true;
1821
+ };
1822
+ // Il client è caduto: da qui l'output va nel ring, e parte la grazia.
1823
+ const detach = (record) => {
1824
+ if (!record || record.ended) return;
1825
+ record.socket = null;
1826
+ if (record.timer) clearTimeout(record.timer);
1827
+ record.timer = setTimeout(() => destroy(record), grace);
1828
+ if (typeof record.timer.unref === 'function') record.timer.unref();
1829
+ };
1830
+ const feed = (record, chunk) => {
1831
+ if (!record || record.ended) return;
1832
+ if (record.socket && record.socket.writable) {
1833
+ // Passthrough: i byte vanno al client come sono arrivati.
1834
+ try { record.socket.write(chunk); return; } catch (_) { /* cade: si passa al ring */ }
1835
+ }
1836
+ for (const frame of record.splitter.push(chunk)) pushRing(record, frame);
1837
+ };
1838
+ // Aggancio: SOLO identità identica (impronta token, route, sessione).
1839
+ const take = ({ attachId, tokenHash, route, session }) => {
1840
+ const record = records.get(attachId);
1841
+ if (!record || record.ended) return null;
1842
+ // Solo un record STACCATO (in grazia): agganciare un record ancora
1843
+ // attaccato vorrebbe dire rubare lo stream a un client vivo. Un socket
1844
+ // DITRUTTO non e' un client vivo — e il suo 'close' puo' arrivare dopo,
1845
+ // quindi lo stato del socket e' la verita' osservabile, non l'evento.
1846
+ if (record.socket && record.socket.destroyed !== true) return null;
1847
+ if (record.tokenHash !== tokenHash || record.route !== route || record.session !== session) return null;
1848
+ return record;
1849
+ };
1850
+ return {
1851
+ hold, attach, detach, feed, take, destroy,
1852
+ size: () => records.size,
1853
+ get: (id) => records.get(id) || null,
1854
+ };
1855
+ }
1856
+
1857
+ // Handshake WS del ramo di ripresa: identico a quello che scrive REJECT_WSS
1858
+ // (stesso Sec-WebSocket-Accept), ma SENZA creare un oggetto ws. Il socket del
1859
+ // nuovo client resta un PASSTHROUGH di frame come nel ramo normale: niente
1860
+ // de-mascheramento, niente re-incapsulamento.
1861
+ const WS_ACCEPT_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
1862
+ function wsHandshakeResponse(req) {
1863
+ const key = req && req.headers && req.headers['sec-websocket-key'];
1864
+ if (!key) return null;
1865
+ const accept = crypto.createHash('sha1').update(String(key) + WS_ACCEPT_GUID).digest('base64');
1866
+ return 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n'
1867
+ + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`;
1868
+ }
1869
+
1613
1870
  function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
1614
1871
 
1615
1872
  // Frame di close costruito a mano: sul ramo "hop morto DOPO il 101" non c'e'
@@ -1648,7 +1905,7 @@ module.exports = {
1648
1905
  isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
1649
1906
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
1650
1907
  peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
1651
- collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
1908
+ collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade, createHubAttachStore,
1652
1909
  probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
1653
1910
  hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
1654
1911
  };
package/lib/server.js CHANGED
@@ -20,6 +20,7 @@ const { requireToken, bearerFrom } = require('./auth/middleware.js');
20
20
  const { filesRoutes } = require('./files/routes.js');
21
21
  const { createOutboxWatcher } = require('./files/watcher.js');
22
22
  const { leggiTelemetria } = require('./files/telemetry.js');
23
+ const { leggiAttivita } = require('./files/activity.js');
23
24
  const VERSION = require('../package.json').version;
24
25
  const { transcribe } = require('./voice/transcribe.js');
25
26
  const { selectProvider } = require('./fleet/provider.js');
@@ -110,8 +111,28 @@ function uiBuildVersion(distDir) {
110
111
  } catch (_) { return null; }
111
112
  }
112
113
 
114
+ // I defaults del bridge ws vengono dalla config del server. E' una funzione a
115
+ // se' perche' il cablaggio sia verificabile senza montare una sessione tmux.
116
+ function wsBridgeDefaults(cfg = {}) {
117
+ const terminal = cfg.terminal && typeof cfg.terminal === 'object' ? cfg.terminal : {};
118
+ return {
119
+ readonlyDefault: cfg.readonlyDefault,
120
+ tmuxBin: cfg.tmuxBin,
121
+ outputRingBytes: terminal.outputRingBytes || undefined,
122
+ captureLines: terminal.captureLines || undefined,
123
+ };
124
+ }
125
+
113
126
  function createServer(opts = {}) {
114
127
  const cfg = loadConfig(opts);
128
+ // UNA istanza per server: trattiene le attach federate quando il client cade,
129
+ // cosi' chi torna con la stessa identita' riprende dal buffer. I parametri
130
+ // vengono dalla config del server, non da valori cablati.
131
+ const hubAttaches = federation.createHubAttachStore({
132
+ graceMs: cfg.terminal && cfg.terminal.hubGraceMs,
133
+ ringBytes: cfg.terminal && cfg.terminal.outputRingBytes,
134
+ maxAttaches: 8,
135
+ });
115
136
  assertLoopback(cfg.bind);
116
137
  // Token holder LIVE (da revisione di sicurezza): requireToken/verify leggono tokenStore.get()
117
138
  // ad ogni richiesta, cosi' una rotazione via Settings API invalida il VECCHIO token
@@ -973,6 +994,12 @@ function createServer(opts = {}) {
973
994
  // Claude via statusline). Null per tutte le altre — assenza
974
995
  // legittima, non un trattino: la riga resta com'era.
975
996
  telemetry: leggiTelemetria(cfg.filesRoot, s.name),
997
+ // Stato del turno pubblicato dagli hook della cella: lavora /
998
+ // ferma / attesa. `null` NON e' «ferma»: e' «non verificato» —
999
+ // nessun evento entro la finestra, oppure una cella che non ha questo
1000
+ // canale (codex-vl, pi, shell). La UI non deve dedurre «ferma» da un
1001
+ // silenzio: e' esattamente l'errore che il titolo del pane fa oggi.
1002
+ attivita: leggiAttivita(cfg.filesRoot, s.name),
976
1003
  };
977
1004
  }));
978
1005
  res.json({ sessions: enriched });
@@ -1035,6 +1062,9 @@ function createServer(opts = {}) {
1035
1062
  instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
1036
1063
  nodePanelPorts: nodePanelPorts(),
1037
1064
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
1065
+ // i parametri del terminale sono configurabili dal server e leggibili
1066
+ // dal client (che ne ha comunque i default iniettabili).
1067
+ terminal: cfg.terminal || null,
1038
1068
  }));
1039
1069
  const apiNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
1040
1070
  const identityMode = cfg.fleetIdentityMode || cfg.fleet?.identity?.mode || 'legacy';
@@ -1249,6 +1279,12 @@ function createServer(opts = {}) {
1249
1279
  });
1250
1280
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1251
1281
  });
1282
+ // Riconciliazione dopo refresh: gli esiti incerti noti al relay (solo
1283
+ // contatori/ID, mai testo) per ricostruire lo stato «verifica» senza reinvii.
1284
+ api.get('/asks-relay/state', (_req, res) => {
1285
+ try { res.json({ attempts: askRelay.state() }); }
1286
+ catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1287
+ });
1252
1288
  // The client re-emits imported events into the local hub (deliverOnly: the
1253
1289
  // notifier is bypassed on purpose). The alert relay hangs off that same call,
1254
1290
  // so ONLY an imported notify can ring this device, and never through the
@@ -1672,13 +1708,13 @@ function createServer(opts = {}) {
1672
1708
  const panelFederato = /^\/api\/route\/[^?#]*\/_\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(pathname)
1673
1709
  && /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''));
1674
1710
  if (!panelFederato && !verify(tokenHolder.value, given)) return socket.destroy();
1675
- federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
1711
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
1676
1712
  return;
1677
1713
  }
1678
1714
  if (pathname.startsWith('/federation/route/')) {
1679
1715
  const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
1680
1716
  if (!ingress) return socket.destroy();
1681
- federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
1717
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
1682
1718
  return;
1683
1719
  }
1684
1720
  if (pathname.startsWith('/api/panel/')) {
@@ -1732,7 +1768,7 @@ function createServer(opts = {}) {
1732
1768
  isValidSession: (name) => sessionExistsImpl(name) && wsScope.allowsSession(name),
1733
1769
  runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
1734
1770
  countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
1735
- defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
1771
+ defaults: wsBridgeDefaults(cfg),
1736
1772
  ptyGrace,
1737
1773
  onAttach: (sess) => attachedWs.set(ws, sess),
1738
1774
  diagnostics,
@@ -1861,4 +1897,4 @@ function start(opts = {}) {
1861
1897
  return server;
1862
1898
  }
1863
1899
 
1864
- module.exports = { createServer, start };
1900
+ module.exports = { createServer, start, wsBridgeDefaults };
package/lib/ws/bridge.js CHANGED
@@ -117,14 +117,53 @@ function sendJson(ws, value) {
117
117
  try { ws.send(JSON.stringify(value)); } catch (_) {}
118
118
  }
119
119
 
120
+ // Ripresa per sequenza : i frame di output sono numerati e l'ultimo tratto
121
+ // resta in un ring per sessione, cosi' un client che torna puo' chiedere SOLO
122
+ // il mancante invece di ridipingere tutto (o peggio, perdere il tratto).
123
+ const OUTPUT_RING_BYTES = 256 * 1024;
124
+ function createOutputRing({ maxBytes = OUTPUT_RING_BYTES } = {}) {
125
+ const limit = Math.max(1, Math.floor(Number(maxBytes) || OUTPUT_RING_BYTES));
126
+ const frames = [];
127
+ let bytes = 0;
128
+ let nextSeq = 0;
129
+ return {
130
+ push(data) {
131
+ const buf = Buffer.from(data);
132
+ const seq = nextSeq++;
133
+ frames.push({ seq, data: buf });
134
+ bytes += buf.length;
135
+ while (bytes > limit && frames.length > 1) bytes -= frames.shift().data.length;
136
+ return seq;
137
+ },
138
+ // Frame che il client (fermo a `seq`) non ha ancora visto. `null` quando il
139
+ // buco NON e' coperto: inventare un delta parziale corromperebbe lo schermo.
140
+ since(seq) {
141
+ const wanted = Number(seq);
142
+ if (!Number.isInteger(wanted) || wanted < 0) return null;
143
+ if (wanted + 1 >= nextSeq) return [];
144
+ if (!frames.length || frames[0].seq > wanted + 1) return null;
145
+ return frames.filter((f) => f.seq > wanted);
146
+ },
147
+ get nextSeq() { return nextSeq; },
148
+ };
149
+ }
150
+
151
+ // Ogni frame di output porta il suo seq nei primi 4 byte (big endian).
152
+ function frameWithSeq(seq, data) {
153
+ const head = Buffer.alloc(4);
154
+ head.writeUInt32BE((Number(seq) || 0) >>> 0, 0);
155
+ return Buffer.concat([head, Buffer.from(data)]);
156
+ }
157
+
120
158
  // Resync del buffer dopo una riconnessione: il client chiede {type:'resync'}
121
159
  // e riceve l'intero pane (scrollback cap 2000 righe) con le sequenze di
122
160
  // escape (-e) per ridipingere lo schermo senza mai mostrarlo vuoto.
123
161
  const RESYNC_MAX_LINES = 2000;
124
- function defaultCapturePane(session, tmuxBin, cb) {
162
+ function defaultCapturePane(session, tmuxBin, captureLines, cb) {
163
+ const lines = Math.max(1, Math.floor(Number(captureLines) || RESYNC_MAX_LINES));
125
164
  try {
126
165
  require('node:child_process').execFile(tmuxBin || 'tmux',
127
- ['capture-pane', '-e', '-p', '-S', `-${RESYNC_MAX_LINES}`, '-t', String(session)],
166
+ ['capture-pane', '-e', '-p', '-S', `-${lines}`, '-t', String(session)],
128
167
  { maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => cb(err, stdout));
129
168
  } catch (e) { cb(e); }
130
169
  }
@@ -267,6 +306,7 @@ function bindWs(ws, deps) {
267
306
  session: msg.session,
268
307
  readonly: defaults.readonlyDefault === true || !!msg.readonly,
269
308
  takeSize,
309
+ ring: createOutputRing({ maxBytes: defaults.outputRingBytes }),
270
310
  ended: false,
271
311
  exitCode: undefined,
272
312
  resumeToken: null,
@@ -277,8 +317,11 @@ function bindWs(ws, deps) {
277
317
  if (ptyGrace) record.resumeToken = ptyGrace.issueToken(record);
278
318
  onAttach(session, ws);
279
319
  record.pty.onData((d) => {
320
+ // Il ring registra SEMPRE: anche mentre nessuno e' attaccato l'output
321
+ // resta disponibile per chi torna con {type:'resume'}.
322
+ const seq = record.ring.push(d);
280
323
  if (!record.ws) return;
281
- try { record.ws.send(Buffer.from(d), { binary: true }); } catch (_) { return; }
324
+ try { record.ws.send(frameWithSeq(seq, d), { binary: true }); } catch (_) { return; }
282
325
  if ((record.ws.bufferedAmount || 0) > MAX_BUFFERED) fail(1011, 'backpressure');
283
326
  });
284
327
  record.pty.onExit((info) => {
@@ -308,11 +351,28 @@ function bindWs(ws, deps) {
308
351
  else if (msg.type === 'input' && !record.readonly) record.pty.write(typeof msg.data === 'string' ? msg.data : '');
309
352
  else if (msg.type === 'key' && !record.readonly) record.pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
310
353
  else if (msg.type === 'action') runAction(session, msg.name); // nav window/pane server-side
354
+ // Liveness applicativa: il ping risponde SEMPRE, anche a sessione muta, e
355
+ // senza toccare il PTY. È il segnale che distingue una connessione viva da
356
+ // una mezzo-aperta (il tunnel morto senza FIN non produce nessun close).
357
+ else if (msg.type === 'ping') sendJson(ws, { type: 'pong', t: msg.t });
358
+ // Ripresa: rimanda solo il tratto che il client non ha visto. Se il buco
359
+ // non e' coperto dal ring si chiede il repaint, mai un delta parziale.
360
+ else if (msg.type === 'resume') {
361
+ const missing = record.ring.since(msg.seq);
362
+ if (missing === null) {
363
+ sendJson(ws, { type: 'resync-needed' });
364
+ } else {
365
+ for (const frame of missing) {
366
+ try { ws.send(frameWithSeq(frame.seq, frame.data), { binary: true }); } catch (_) { break; }
367
+ }
368
+ sendJson(ws, { type: 'link', state: 'live' });
369
+ }
370
+ }
311
371
  // Resync post-riconnessione: ridipinge il buffer del client dal pane
312
372
  // reale. Best-effort — se il capture fallisce lo stream live continua e
313
373
  // il client resta sul buffer che aveva (mai uno schermo vuoto forzato).
314
374
  else if (msg.type === 'resync') {
315
- capturePane(session, defaults.tmuxBin || 'tmux', (err, text) => {
375
+ capturePane(session, defaults.tmuxBin || 'tmux', defaults.captureLines, (err, text) => {
316
376
  if (err) return;
317
377
  sendJson(ws, { type: 'snapshot', data: String(text || '') });
318
378
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.39",
3
+ "version": "0.9.41",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
@@ -45,15 +45,16 @@
45
45
  "express": "^4.21.0",
46
46
  "multer": "^2.2.0",
47
47
  "qrcode-terminal": "^0.12.0",
48
+ "smol-toml": "1.9.0",
48
49
  "web-push": "^3.6.7",
49
50
  "ws": "^8.18.0"
50
51
  },
51
52
  "optionalDependencies": {
52
- "@mmmbuto/node-pty-android-arm64": "^1.1.2",
53
53
  "@lydell/node-pty-darwin-arm64": "^1.2.0-beta.14",
54
54
  "@lydell/node-pty-darwin-x64": "^1.2.0-beta.14",
55
55
  "@lydell/node-pty-linux-arm64": "^1.2.0-beta.12",
56
- "@lydell/node-pty-linux-x64": "^1.2.0-beta.12"
56
+ "@lydell/node-pty-linux-x64": "^1.2.0-beta.12",
57
+ "@mmmbuto/node-pty-android-arm64": "^1.1.2"
57
58
  },
58
59
  "engines": {
59
60
  "node": ">=18"