@mmmbuto/nexuscrew 0.9.63 → 0.9.65

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -21
  3. package/docs/NOTIFICATIONS.md +34 -0
  4. package/docs/SECURITY.md +13 -12
  5. package/frontend/dist/assets/index-DkYRC1vV.js +169 -0
  6. package/frontend/dist/assets/{index-D1cTtNED.css → index-Dwd37Z_R.css} +2 -2
  7. package/frontend/dist/index.html +2 -2
  8. package/frontend/dist/version.json +1 -1
  9. package/lib/cells/routes.js +37 -3
  10. package/lib/cli/commands.js +1 -1
  11. package/lib/config.js +1 -1
  12. package/lib/fleet/builtin.js +3 -0
  13. package/lib/fleet/cell-lease-server.js +1 -1
  14. package/lib/fleet/cell-lease.js +1 -1
  15. package/lib/fleet/lease-client.js +1 -1
  16. package/lib/fleet/lease-verifier.js +1 -1
  17. package/lib/live-host/attest.js +39 -0
  18. package/lib/live-host/bridge.js +81 -21
  19. package/lib/live-host/entry.js +49 -0
  20. package/lib/live-host/registry.js +111 -0
  21. package/lib/live-host/reserved.js +12 -0
  22. package/lib/mcp/cells.js +37 -0
  23. package/lib/mcp/server.js +7 -1
  24. package/lib/mcp/tools.js +7 -1
  25. package/lib/notify/ask-relay.js +36 -166
  26. package/lib/notify/event-feed-asks-routes.js +18 -3
  27. package/lib/notify/event-feed-client.js +85 -10
  28. package/lib/notify/event-feed-notices-routes.js +135 -0
  29. package/lib/notify/event-feed-producers.js +21 -0
  30. package/lib/notify/event-feed-routes.js +30 -11
  31. package/lib/notify/notice-dismissal-queue.js +336 -0
  32. package/lib/notify/notice-dismissals.js +178 -0
  33. package/lib/notify/notice-relay.js +128 -0
  34. package/lib/notify/owner-channel.js +172 -0
  35. package/lib/proxy/federation.js +9 -0
  36. package/lib/proxy/resource-acl.js +6 -0
  37. package/lib/server.js +229 -12
  38. package/lib/tmux/actions.js +1 -1
  39. package/package.json +2 -2
  40. package/frontend/dist/assets/index-maPH0v_e.js +0 -169
package/lib/server.js CHANGED
@@ -31,6 +31,9 @@ const { cellsRoutes, publicCells } = require('./cells/routes.js');
31
31
  const { liveHostRoutes } = require('./live-host/routes.js');
32
32
  const { createLiveHostStore, liveHostPath } = require('./live-host/store.js');
33
33
  const { createLiveBridge } = require('./live-host/bridge.js');
34
+ const { createLiveThreadRegistry, liveThreadsPath } = require('./live-host/registry.js');
35
+ const { createLiveAttestation } = require('./live-host/attest.js');
36
+ const { buildLiveEntry } = require('./live-host/entry.js');
34
37
  const { createLiveHostFederation } = require('./live-host/federation.js');
35
38
  const { fsRoutes } = require('./fs/routes.js');
36
39
  const nodesStore = require('./nodes/store.js');
@@ -88,7 +91,11 @@ const { createEventFeedClient } = require('./notify/event-feed-client.js');
88
91
  const { createAskReceipts } = require('./notify/ask-receipts.js');
89
92
  const { createAskAnswerService } = require('./notify/ask-answer-service.js');
90
93
  const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
94
+ const { createEventFeedNoticesRoutes } = require('./notify/event-feed-notices-routes.js');
95
+ const { createNoticeDismissals } = require('./notify/notice-dismissals.js');
91
96
  const { createAskRelay } = require('./notify/ask-relay.js');
97
+ const { createNoticeRelay } = require('./notify/notice-relay.js');
98
+ const { createNoticeDismissalQueue, createNoticeDismissalDrainer } = require('./notify/notice-dismissal-queue.js');
92
99
  const { createImportedDismissalDrainer } = require('./notify/imported-dismissals.js');
93
100
  const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
94
101
  const { readFederationEventsEnabled } = require('./config.js');
@@ -218,6 +225,12 @@ function createServer(opts = {}) {
218
225
  const feedHistory = createEventFeedHistory({
219
226
  filePath: path.join(notifyDir, 'event-feed-history.json'),
220
227
  });
228
+ // Notices the OWNER cleared: ids and timestamps only, expiring with the
229
+ // history they filter. This is what makes a cleanup true on every device —
230
+ // the owner's snapshot stops carrying the entry, so a reload cannot revive it.
231
+ const noticeDismissals = createNoticeDismissals({
232
+ filePath: path.join(notifyDir, 'notice-dismissals.json'),
233
+ });
221
234
  // Kill switch: strict boolean, read FRESH on every use so flipping it in
222
235
  // config.json acts on live streams without a restart.
223
236
  const eventsEnabled = () => readFederationEventsEnabled({ configJsonPath: cfg.configPath, federationEventsEnabled: cfg.federationEventsEnabled });
@@ -1017,7 +1030,7 @@ function createServer(opts = {}) {
1017
1030
  return cellScoper.resolve({ trust: 'federated', visited: [] });
1018
1031
  }
1019
1032
  };
1020
- // Scope celle (NC-E): un punto solo decide cosa un peer federato vede e puo'
1033
+ // Scope celle: un punto solo decide cosa un peer federato vede e puo'
1021
1034
  // toccare. Sta qui, prima di ogni route, perche' i canali da cui una cella
1022
1035
  // trapela sono molti e crescono: filtrarli uno per uno significa dimenticarne
1023
1036
  // uno, e un permesso che vale su quasi tutti i canali non e' un permesso.
@@ -1337,6 +1350,33 @@ function createServer(opts = {}) {
1337
1350
  cellForSession: ownerAskScope.cellForSession,
1338
1351
  readonly: proxyReadonly,
1339
1352
  }));
1353
+ // Owner-side notice dismissals: the same action gate and the same chain as
1354
+ // the ask write-back, with its own rate instance. Mounted beside the asks,
1355
+ // before the feed router, because it is a write-back path of the feed.
1356
+ api.use('/event-feed/notices', createEventFeedNoticesRoutes({
1357
+ nodesPath,
1358
+ localNodeId: federatedNodeId,
1359
+ originResolver: createOriginResolver({
1360
+ localNodeId: federatedNodeId,
1361
+ hopSecret: () => hopSecret,
1362
+ }),
1363
+ eventsEnabled,
1364
+ history: feedHistory,
1365
+ dismissals: noticeDismissals,
1366
+ // The closure inherits scope and cell of the original notice, so it is
1367
+ // delivered to exactly the peers that could see it. It enters the history
1368
+ // too: a peer that missed the live frame recovers it from the snapshot,
1369
+ // where toRemoteNotice drops it by type.
1370
+ closeNotice: ({ scope, cellId, eventId }) => {
1371
+ recordAndPublish({
1372
+ producer: scope === 'node' ? 'notify-closed-node' : 'notify-closed',
1373
+ cellId: scope === 'node' ? null : cellId,
1374
+ payload: { eventId, ts: Date.now() },
1375
+ });
1376
+ },
1377
+ readonly: proxyReadonly,
1378
+ log: opts.log || console.log,
1379
+ }));
1340
1380
  api.use('/event-feed', createEventFeedRoutes({
1341
1381
  nodesPath,
1342
1382
  localNodeId: federatedNodeId,
@@ -1346,6 +1386,7 @@ function createServer(opts = {}) {
1346
1386
  }),
1347
1387
  eventFeed,
1348
1388
  history: feedHistory,
1389
+ noticeDismissals,
1349
1390
  asksStore,
1350
1391
  fleetP,
1351
1392
  cellForSession: ownerAskScope.cellForSession,
@@ -1374,6 +1415,60 @@ function createServer(opts = {}) {
1374
1415
  // Il body va parsato QUI: il router `api` non monta un parser globale, ogni
1375
1416
  // rotta porta il suo (`express.json`) — vedi /sessions, /notify, /asks.
1376
1417
  // Senza, `req.body` e' undefined, il relay legge un body vuoto e risponde
1418
+ // Notice dismissals, client half: the relay that carries a dismissal to the
1419
+ // owner, the durable queue of the intents that could not be delivered yet, and
1420
+ // the drainer that retries them. Same channel as the ask answers (one
1421
+ // implementation of the chain), same binding as the imported dismissals: a
1422
+ // refusal stays blocked until the pairing to that owner changes.
1423
+ const noticeRelay = createNoticeRelay({
1424
+ nodesPath, slotVerifier: askReverseVerifier,
1425
+ loadStore: () => nodesStore.loadStore(nodesPath),
1426
+ peers: federatedPeers,
1427
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
1428
+ localToken: () => tokenHolder.value,
1429
+ log: opts.log || console.log,
1430
+ });
1431
+ const noticeDismissalQueue = createNoticeDismissalQueue({
1432
+ filePath: path.join(notifyDir, 'notice-dismissal-queue.json'),
1433
+ });
1434
+ // Durability signal for the operator's answer: the queue honours the intent
1435
+ // in memory when the disk refuses the write (that policy stands), but a
1436
+ // record that will not survive a restart is NOT the normal durable success,
1437
+ // so queued answers declare it instead of looking healthy. Empty = healthy.
1438
+ const queueDurability = () => {
1439
+ try {
1440
+ const st = noticeDismissalQueue.status();
1441
+ return st && st.degraded ? { durable: false, degraded: st.degraded } : {};
1442
+ } catch (_) { return {}; }
1443
+ };
1444
+ // One durable record per cleared notice. A refused write keeps the in-memory
1445
+ // truth (the intent was honoured here anyway) and only loses durability; a
1446
+ // refused enqueue means the intent was NOT recorded, so nothing is left behind.
1447
+ function enqueueNoticeDismissals(ownerId, eventIds, syncState) {
1448
+ const added = [];
1449
+ for (const eventId of eventIds) {
1450
+ const queued = noticeDismissalQueue.enqueue({ ownerId, eventId });
1451
+ if (!queued.ok) {
1452
+ for (const done of added) noticeDismissalQueue.confirm(ownerId, done);
1453
+ return false;
1454
+ }
1455
+ added.push(eventId);
1456
+ }
1457
+ if (syncState === 'blocked') {
1458
+ for (const eventId of eventIds) noticeDismissalQueue.update(ownerId, eventId, { syncState: 'blocked', lastReason: 'unsupported' });
1459
+ }
1460
+ return true;
1461
+ }
1462
+ const noticeDismissalDrainer = createNoticeDismissalDrainer({
1463
+ queue: noticeDismissalQueue, relay: noticeRelay,
1464
+ readonly: proxyReadonly, bindingForOwner: dismissalBinding,
1465
+ // Same seam as the feed client's poll: a test (or an installation with its
1466
+ // own rhythm) can shorten the retry interval without touching the policy.
1467
+ ...(Number.isInteger(cfg.noticeDismissalDrainMs) && cfg.noticeDismissalDrainMs > 0
1468
+ ? { intervalMs: cfg.noticeDismissalDrainMs } : {}),
1469
+ log: opts.log || console.log,
1470
+ });
1471
+
1377
1472
  // 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
1378
1473
  // federata non ha mai potuto attraversare, e nessun test copriva questa
1379
1474
  // rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
@@ -1517,6 +1612,106 @@ function createServer(opts = {}) {
1517
1612
  try { res.json({ attempts: askRelay.state() }); }
1518
1613
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1519
1614
  });
1615
+ // Operator surface for the notice dismissals. LOCAL and authenticated, never in
1616
+ // the federation allowlist: the remote surface is the owner's (the routes under
1617
+ // /event-feed/notices). The intent is honoured HERE first — the card leaves this
1618
+ // node's view — and the owner's copy is either delivered or queued, with the
1619
+ // answer saying which.
1620
+ api.post('/notices-relay', express.json({ limit: '8kb' }), async (req, res) => {
1621
+ try {
1622
+ const b = req.body || {};
1623
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: mutation blocked' });
1624
+ if (req.headers[HOP_HEADER]) return res.status(403).json({ error: 'local operator required' });
1625
+ if (!b || Array.isArray(b) || typeof b.action !== 'string') return res.status(400).json({ error: 'invalid notices body' });
1626
+ if (b.action === 'dismiss') {
1627
+ if (Object.keys(b).some((key) => !['action', 'ownerId', 'eventId'].includes(key))
1628
+ || !/^[a-f0-9]{32}$/.test(String(b.ownerId || ''))
1629
+ || !/^[0-9a-f-]{36}$/.test(String(b.eventId || ''))
1630
+ || b.ownerId === federatedNodeId()) {
1631
+ return res.status(400).json({ error: 'invalid local dismissal body' });
1632
+ }
1633
+ const out = await noticeRelay.relayNoticeDismiss({ ownerId: b.ownerId, eventId: b.eventId });
1634
+ if (out.ok) {
1635
+ if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
1636
+ return res.json({ dismissed: true, ...(out.idempotent ? { idempotent: true } : {}), ...(out.gone ? { gone: true } : {}) });
1637
+ }
1638
+ // The owner is not reachable NOW (or has no such surface at all): the
1639
+ // local intent stands, and the queue carries the rest. A reachable owner
1640
+ // that REFUSES is never queued — its answer is the decision.
1641
+ if (out.transient === true || out.reason === 'unsupported') {
1642
+ const queued = noticeDismissalQueue.enqueue({ ownerId: b.ownerId, eventId: b.eventId });
1643
+ if (!queued.ok) return res.status(503).json({ error: 'dismissal queue unavailable', reason: queued.reason });
1644
+ if (out.reason === 'unsupported') noticeDismissalQueue.update(b.ownerId, b.eventId, { syncState: 'blocked', lastReason: 'unsupported' });
1645
+ if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
1646
+ res.json({ dismissed: true, scope: 'local', ownerSync: out.reason === 'unsupported' ? 'blocked' : 'pending', ...queueDurability() });
1647
+ noticeDismissalDrainer.nudge();
1648
+ return;
1649
+ }
1650
+ return res.status(out.code || 500).json({ error: out.error || 'unknown', reason: out.reason });
1651
+ }
1652
+ if (b.action === 'dismiss-all') {
1653
+ if (Object.keys(b).some((key) => !['action', 'owners'].includes(key))
1654
+ || !Array.isArray(b.owners) || b.owners.length === 0 || b.owners.length > 100
1655
+ || b.owners.some((owner) => !/^[a-f0-9]{32}$/.test(String(owner || '')) || owner === federatedNodeId())) {
1656
+ return res.status(400).json({ error: 'invalid dismiss-all body' });
1657
+ }
1658
+ const results = {};
1659
+ for (const ownerId of [...new Set(b.owners)]) {
1660
+ // What this node is SHOWING for that owner: the whole visible set is
1661
+ // what the operator asked to clear, and it is the set whose local
1662
+ // intent has to outlive a reload or a restart of this node.
1663
+ const view = (eventFeedClient.state().views || []).find((v) => v.ownerId === ownerId);
1664
+ const affected = ((view && view.notifications) || [])
1665
+ .map((n) => n && n.eventId).filter((eventId) => typeof eventId === 'string' && eventId !== '');
1666
+ const out = await noticeRelay.relayNoticeDismissAll({ ownerId });
1667
+ if (out.ok) {
1668
+ // The owner took it: its own store stops serving these notices, so
1669
+ // the view forgets them here as well, exactly like the single route.
1670
+ for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
1671
+ results[ownerId] = { ok: true, dismissed: out.dismissed || 0 };
1672
+ continue;
1673
+ }
1674
+ // The owner is not reachable NOW (`pending`) or has no notices surface
1675
+ // at all (`blocked`): the local intent stands, durably — one queue
1676
+ // record per cleared notice is what keeps them out of the view on the
1677
+ // next snapshot — and the owner's copy is carried later, or never.
1678
+ const localSync = out.reason === 'unsupported' ? 'blocked' : (out.transient === true ? 'pending' : null);
1679
+ if (localSync === null) { results[ownerId] = { failed: out.reason || 'refused' }; continue; }
1680
+ if (!enqueueNoticeDismissals(ownerId, affected, localSync)) {
1681
+ results[ownerId] = { failed: 'queue-unavailable' };
1682
+ continue;
1683
+ }
1684
+ for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
1685
+ // Queued answers depend on the durable write: a queue that cannot
1686
+ // persist says so per owner, instead of looking like the healthy
1687
+ // pending/blocked that survives a restart.
1688
+ results[ownerId] = localSync === 'blocked'
1689
+ ? { blocked: 'unsupported', ...queueDurability() }
1690
+ : { pending: affected.length, ...queueDurability() };
1691
+ if (localSync === 'pending') noticeDismissalDrainer.nudge();
1692
+ }
1693
+ return res.json({ results });
1694
+ }
1695
+ return res.status(400).json({ error: 'azione non riconosciuta' });
1696
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1697
+ });
1698
+ api.get('/notices-relay/capability', (req, res) => {
1699
+ const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
1700
+ if (!/^[a-f0-9]{32}$/.test(ownerId)) return res.status(400).json({ error: 'ownerId richiesto' });
1701
+ let canDismiss = false;
1702
+ try {
1703
+ const st = nodesStore.loadStore(nodesPath);
1704
+ const peer = st && (st.nodes || []).find((node) => node && node.nodeId === ownerId);
1705
+ const slot = peer && peer.reversePool && Array.isArray(peer.reversePool.slots)
1706
+ ? peer.reversePool.slots[peer.reversePool.activeSlot] : null;
1707
+ canDismiss = !proxyReadonly() && !req.headers[HOP_HEADER] && !!peer && (
1708
+ (peer.direction === 'outbound' && !!peer.token && !!peer.localPort)
1709
+ || (peer.direction === 'inbound' && peer.shared === true && peer.askReplyAccess === true
1710
+ && peer.eventsAccess === true && peer.cellVisibility !== 'none'
1711
+ && !!slot && Number.isInteger(slot.port)));
1712
+ } catch (_) { canDismiss = false; }
1713
+ res.json({ ownerId, canDismiss });
1714
+ });
1520
1715
  // The client re-emits imported events into the local hub (deliverOnly: the
1521
1716
  // notifier is bypassed on purpose). The alert relay hangs off that same call,
1522
1717
  // so ONLY an imported notify can ring this device, and never through the
@@ -1545,6 +1740,13 @@ function createServer(opts = {}) {
1545
1740
  if (!bridgeReadonly()) for (const ask of asks) askAlertRegistry.adopt(ownerId, ask, ask.ownerAskTs === undefined ? ask.ts : ask.ownerAskTs);
1546
1741
  },
1547
1742
  isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
1743
+ // A notice this node cleared locally but could not hand to the owner (queued
1744
+ // as pending, or blocked for good) must not return to the view: the record in
1745
+ // the queue is the node's intent, and it is what survives a restart. The
1746
+ // read is the same one the operator's answer is built from.
1747
+ isNoticeDismissed: (ownerId, eventId) => {
1748
+ try { return noticeDismissalQueue.get(ownerId, eventId) !== null; } catch (_) { return false; }
1749
+ },
1548
1750
  onAskClosed: (ownerId, ownerAskId, outcome, ownerAskTs) => asksStore.closeImported({ ownerId, ownerAskId, outcome, ownerAskTs }),
1549
1751
  loadStore: () => nodesStore.loadStore(nodesPath),
1550
1752
  token: () => tokenHolder.value,
@@ -1691,6 +1893,23 @@ function createServer(opts = {}) {
1691
1893
  }),
1692
1894
  getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
1693
1895
  }));
1896
+ // One store and one bridge serve the cell directory (the Live entry and the
1897
+ // verified label), the live-host routes and the federation forwarder. The
1898
+ // registry of Live threads is persisted next to the designation so a restart
1899
+ // does not make a living Live look absent.
1900
+ const liveHostStore = createLiveHostStore({ filePath: liveHostPath(cfg) });
1901
+ const liveRegistry = createLiveThreadRegistry({ filePath: liveThreadsPath(cfg) });
1902
+ const liveBridge = createLiveBridge({
1903
+ cfg,
1904
+ fleetP,
1905
+ tokenGet: () => tokenHolder.value,
1906
+ filesRoot: cfg.filesRoot,
1907
+ log: opts.log || console.log,
1908
+ registry: liveRegistry,
1909
+ });
1910
+ // Startup reconciliation never blocks the server and never throws: it only
1911
+ // drops entries the daemon definitely reports as gone.
1912
+ Promise.resolve().then(() => liveBridge.reconcile()).catch(() => {});
1694
1913
  api.use('/cells', cellsRoutes({
1695
1914
  fleetP,
1696
1915
  diagnostics,
@@ -1700,6 +1919,10 @@ function createServer(opts = {}) {
1700
1919
  engine: meta && meta.engine,
1701
1920
  })),
1702
1921
  readonly: proxyReadonly,
1922
+ liveEntry: ({ instanceId: id, now: clock }) => buildLiveEntry({
1923
+ store: liveHostStore, bridge: liveBridge, fleetP, instanceId: id, now: clock,
1924
+ }),
1925
+ liveAttest: createLiveAttestation({ registry: liveRegistry, bridge: liveBridge }),
1703
1926
  }));
1704
1927
  // Live host cell: one hostCell per node with CAS. The store
1705
1928
  // lives next to the token (same isolated dir in tests); the proxy refuses
@@ -1707,22 +1930,15 @@ function createServer(opts = {}) {
1707
1930
  // behind a per-peer permission denied by default (liveHostAccess).
1708
1931
  // Fetta 3: il ponte risolve il puntamento all'avvio di una Live (POST /bridge
1709
1932
  // nello stesso gruppo, stessa auth, stesso local-only). Isolabile da cfg
1710
- // (rev5): spento non crea connessioni e la Live resta standard.
1933
+ // Spento non crea connessioni e la Live resta standard.
1711
1934
  // One store instance serves both sides of a remote designation: the routes
1712
1935
  // (owner side) and the forwarder that records the pointer to a peer (client
1713
1936
  // side). Two instances over the same file would work, one is honest.
1714
- const liveHostStore = createLiveHostStore({ filePath: liveHostPath(cfg) });
1715
1937
  api.use('/live-host', liveHostRoutes({
1716
1938
  fleetP,
1717
1939
  store: liveHostStore,
1718
1940
  readonly: proxyReadonly,
1719
- bridge: createLiveBridge({
1720
- cfg,
1721
- fleetP,
1722
- tokenGet: () => tokenHolder.value,
1723
- filesRoot: cfg.filesRoot,
1724
- log: opts.log || console.log,
1725
- }),
1941
+ bridge: liveBridge,
1726
1942
  // Client side: read the owner's state and forward the bridge resolution to
1727
1943
  // the node the designation points at. Owner side: accept a forwarded
1728
1944
  // resolution behind the same per-peer gate and the same rate limiting as
@@ -1777,6 +1993,7 @@ function createServer(opts = {}) {
1777
1993
  // Store-root revision of the peer access policy: the CAS value the UI
1778
1994
  // must send back with every preset write (stale revision -> 409).
1779
1995
  importedDismissalDrainer.nudge();
1996
+ noticeDismissalDrainer.nudge();
1780
1997
  res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
1781
1998
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1782
1999
  });
@@ -1908,14 +2125,14 @@ function createServer(opts = {}) {
1908
2125
  diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
1909
2126
  port: runtimePort(), platform: cfg.platform || process.platform,
1910
2127
  });
1911
- updater.start(); startManagedTunnels(); importedDismissalDrainer.start();
2128
+ updater.start(); startManagedTunnels(); importedDismissalDrainer.start(); noticeDismissalDrainer.start();
1912
2129
  });
1913
2130
  server.on('close', () => {
1914
2131
  diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
1915
2132
  watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
1916
2133
  // Nessun timer di ritentativo sopravvive alla chiusura del server.
1917
2134
  if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
1918
- importedDismissalDrainer.stop(); eventFeedClient.stop();
2135
+ importedDismissalDrainer.stop(); noticeDismissalDrainer.stop(); eventFeedClient.stop();
1919
2136
  for (const timer of reverseWatchers.values()) clearInterval(timer);
1920
2137
  reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
1921
2138
  // Il pannello non sopravvive al control plane: senza requireToken sopra,
@@ -161,7 +161,7 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
161
161
  // then the pane is revalidated before the separate Enter. Other clients
162
162
  // still receive a short separation between paste and submit.
163
163
  //
164
- // NC-Q: L'ATTESA SCALA COL PAYLOAD, e prima era una costante. Il tempo che
164
+ // L'ATTESA SCALA COL PAYLOAD, e prima era una costante. Il tempo che
165
165
  // un TUI impiega a ingerire un bracketed paste cresce con la lunghezza —
166
166
  // Claude Code sopra una certa taglia lo COLLASSA («paste again to expand»)
167
167
  // — mentre l'attesa era fissa. Oltre quella soglia l'Enter arriva mentre il
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.63",
4
- "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
3
+ "version": "0.9.65",
4
+ "description": "Faithful browser tmux client \u2014 attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
7
7
  "nexuscrew": "bin/nexuscrew.js"