@mmmbuto/nexuscrew 0.9.58 → 0.9.60

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.
@@ -44,7 +44,7 @@ const ASK_KEYS = new Set(['question', 'options', 'session', 'target']);
44
44
  // del nodo di origine, non un chiamante locale (stessa regola di FEDERATED_KEYS).
45
45
  // `ownerNode`/`originNode` qualificano l'identita', `askId` e' l'id con cui
46
46
  // l'owner conosce la domanda (serve alla risposta per tornare sul bersaglio).
47
- const FEDERATED_ASK_KEYS = new Set(['askId', 'ownerNode', 'originNode', 'originCell', 'closeOutcome']);
47
+ const FEDERATED_ASK_KEYS = new Set(['askId', 'ownerNode', 'originNode', 'originCell', 'closeOutcome', 'ownerAskTs']);
48
48
  const RATE_MAX = 6;
49
49
  const RATE_WINDOW_MS = 60 * 1000;
50
50
  const RATE_MAX_BUCKETS = 64;
@@ -160,7 +160,7 @@ function createClosureFanout({
160
160
  // l'unico lato che puo' riprovare: il ricevente non ha rotta verso l'owner).
161
161
  retry = null,
162
162
  } = {}) {
163
- async function dispatch({ askId, outcome, session, retryOnFailure = true, targets = null }) {
163
+ async function dispatch({ askId, ownerAskTs, outcome, session, retryOnFailure = true, targets = null }) {
164
164
  if (!dispatcher) return [];
165
165
  const self = localNodeId();
166
166
  // `targets` esplicito = ritentativo: si va SOLO verso i pendenti, non di
@@ -178,7 +178,8 @@ function createClosureFanout({
178
178
  resource: '/asks',
179
179
  target,
180
180
  origin: { node: self, cell: session || 'unknown' },
181
- payload: { askId, closeOutcome: outcome, ownerNode: self },
181
+ payload: { askId, closeOutcome: outcome, ownerNode: self,
182
+ ...(Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? { ownerAskTs } : {}) },
182
183
  });
183
184
  out.push({ target, status: r && r.status, ...(r && r.reason ? { reason: r.reason } : {}) });
184
185
  } catch (e) {
@@ -197,7 +198,7 @@ function createClosureFanout({
197
198
  const pendenti = out
198
199
  .filter((r) => !CLOSURE_DONE_STATUSES.has(r.status) && !CLOSURE_FINAL_STATUSES.has(r.status))
199
200
  .map((r) => r.target);
200
- try { retry.enqueue({ askId, outcome, session, targets: pendenti }); } catch (_) {}
201
+ try { retry.enqueue({ askId, ownerAskTs, outcome, session, targets: pendenti }); } catch (_) {}
201
202
  }
202
203
  return out;
203
204
  }
@@ -464,7 +465,7 @@ function notifyRoutes({
464
465
  if (isClosure) {
465
466
  const owner = resolved.origin.node;
466
467
  const closed = asks.closeImported({
467
- ownerId: owner, ownerAskId: b.askId, outcome: b.closeOutcome,
468
+ ownerId: owner, ownerAskId: b.askId, ownerAskTs: b.ownerAskTs, outcome: b.closeOutcome,
468
469
  });
469
470
  // closeImported rifiuta ({ok:false, reason:'store-unreadable'}) se lo
470
471
  // store locale e' illeggibile: l'alias NON e' stato chiuso. Rispondere
@@ -480,13 +481,17 @@ function notifyRoutes({
480
481
  const reason = closed.reason || 'store-unreadable';
481
482
  return res.status(503).json({ status: 'unavailable', reason });
482
483
  }
483
- if (closed.changed && closed.ask) {
484
- // Il frame porta l'id LOCALE: e' quello con cui questa UI identifica
485
- // la card, e senza di esso la card resterebbe a schermo.
484
+ if (!closed.generationMismatch) {
485
+ // The frame must reach the exported view even when the store had
486
+ // nothing to change (alias absent, or already closed by a
487
+ // duplicate): a fresh page rebuilds its cards from that view, and
488
+ // only a generation mismatch may refuse the closure.
486
489
  notifier.deliverOnlyRaw({
487
490
  type: b.closeOutcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
488
- id: closed.ask.id,
491
+ id: (closed.ask && closed.ask.id) || b.askId,
489
492
  ownerId: owner,
493
+ ownerAskId: (closed.ask && closed.ask.ownerAskId) || b.askId,
494
+ ...(Number.isSafeInteger(b.ownerAskTs) && b.ownerAskTs > 0 ? { ownerAskTs: b.ownerAskTs } : {}),
490
495
  });
491
496
  }
492
497
  return res.json({ status: 'delivered', closed: closed.changed });
@@ -506,28 +511,36 @@ function notifyRoutes({
506
511
  // L'id con cui l'OWNER conosce la domanda: e' quello che la risposta
507
512
  // deve citare. Il nostro `id` locale resta nostro.
508
513
  ownerAskId: b.askId,
514
+ ownerAskTs: b.ownerAskTs,
509
515
  originNode: owner,
510
516
  originCell: resolved.origin.cell,
511
517
  });
512
518
  if (!out.ok) {
513
519
  return res.status(out.reason === 'cap' ? 429 : 400).json({ status: 'refused', reason: out.reason, error: out.error });
514
520
  }
521
+ if (out.suppressed || out.elided) {
522
+ const reason = out.suppressed ? 'suppressed' : 'elided';
523
+ try { log(`ask ingress ${owner}/${b.askId}: ${reason}, ui=0 push=0`); } catch (_) {}
524
+ return res.json({ status: 'delivered', ...(out.suppressed ? { suppressed: true } : { elided: true }), ownerId: owner, alertStatus: reason, alert: { ui: 0, push: 0, uiAttempted: false, pushAttempted: false } });
525
+ }
515
526
  const ask = out.ask;
516
527
  // deliverOnly: consegna locale alla UI, NIENTE pubblicazione sul feed e
517
528
  // niente ri-esportazione. E' l'invariante che uccide il loop A->B->A per
518
529
  // costruzione — un ask importato non torna mai indietro, esattamente
519
530
  // come una notify federata.
520
- notifier.deliverOnlyRaw({ type: 'ask', ask });
521
- await notifier.deliverOnly({
531
+ const cardUi = notifier.deliverOnlyRaw({ type: 'ask', ask });
532
+ const alert = await notifier.deliverOnly({
522
533
  title: `domanda da ${ask.session}`,
523
534
  body: ask.question,
524
535
  urgency: 'high',
525
536
  session: ask.session,
526
537
  lang: 'it',
527
- askId: ask.id,
538
+ ownerId: owner, askId: ask.ownerAskId || ask.id,
539
+ ...(Number.isSafeInteger(ask.ownerAskTs) && ask.ownerAskTs > 0 ? { ownerAskTs: ask.ownerAskTs } : { ownerAskFingerprint: asks.askFingerprint(ask) }),
528
540
  url: `/#ask=${ask.id}`,
529
541
  });
530
- return res.json({ status: 'delivered', id: ask.id, ownerId: ask.ownerId });
542
+ try { log(`ask ingress ${owner}/${b.askId}: accepted, cardUi=${cardUi || 0} ui=${alert.ui} push=${alert.push} alert=${alert.alertStatus}`); } catch (_) {}
543
+ return res.json({ status: 'delivered', id: ask.id, ownerId: ask.ownerId, deduped: out.deduped === true, cardUi: cardUi || 0, alertStatus: alert.alertStatus, alert });
531
544
  }
532
545
 
533
546
  // --- domanda locale ----------------------------------------------------
@@ -565,10 +578,11 @@ function notifyRoutes({
565
578
  ...(ask.options ? { options: ask.options } : {}),
566
579
  session: ask.session,
567
580
  askId: ask.id,
581
+ ownerAskTs: ask.ts,
568
582
  ownerNode: self,
569
583
  },
570
584
  });
571
- fanout.push({ target, status: r && r.status, ...(r && r.reason ? { reason: r.reason } : {}) });
585
+ fanout.push({ target, status: r && r.status, ...(r && r.reason ? { reason: r.reason } : {}), ...(r && r.alertStatus ? { alertStatus: r.alertStatus, alert: r.alert } : {}), ...(r && r.suppressed === true ? { suppressed: true } : {}), ...(r && r.elided === true ? { elided: true } : {}), ...(r && typeof r.deduped === 'boolean' ? { deduped: r.deduped } : {}) });
572
586
  } catch (e) {
573
587
  fanout.push({ target, status: 'unknown', reason: 'dispatch-threw' });
574
588
  try { log(`ask fan-out verso ${target} fallito: ${String(e && e.message || e)}`); } catch (_) {}
@@ -588,7 +602,7 @@ function notifyRoutes({
588
602
  urgency: 'high',
589
603
  session: ask.session,
590
604
  lang: 'it',
591
- askId: ask.id,
605
+ ownerId: self, askId: ask.id, ownerAskTs: ask.ts,
592
606
  url: `/#ask=${ask.id}`,
593
607
  });
594
608
  res.status(201).json({ id: ask.id, ...(fanout.length ? { fanout } : {}) });
package/lib/server.js CHANGED
@@ -7,7 +7,8 @@ const { execFileSync } = require('node:child_process');
7
7
  const express = require('express');
8
8
  const { WebSocketServer } = require('ws');
9
9
  const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
10
- const { listSessions, listPanes, attachedClients, setSessionVisibility } = require('./tmux/list.js');
10
+ const { attachedClients, setSessionVisibility, listFleetPanes, sessionsFromPanes } = require('./tmux/list.js');
11
+ const { createTmuxReadWindow } = require('./tmux-reads.js');
11
12
  const { statiSupervisore: statiSupervisoreDi, senzaSupervisore } = require('./tmux/supervisor-pane.js');
12
13
  const { runAction, pasteToSession, submitToSession } = require('./tmux/actions.js');
13
14
  const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
@@ -53,6 +54,7 @@ const { createDispatcher } = require('./audio/dispatch.js');
53
54
  const { createCellScopeGuard } = require('./cells/scope-guard.js');
54
55
  const { createCellScope } = require('./cells/scope.js');
55
56
  const { cellIdFromTmuxSession } = require('./fleet/definitions.js');
57
+ const { createOwnerAskScope } = require('./notify/owner-ask-scope.js');
56
58
  // Budget a tre finestre, riusato dalle notifiche federate: e' gia' scritto per
57
59
  // limitare il danno di un nodo che mente sulla propria cella (il tetto per
58
60
  // target si calcola sul NODO, che la catena visited verifica).
@@ -75,6 +77,8 @@ const { createPushService } = require('./notify/push.js');
75
77
  const { createPushRelay, createPushDedup } = require('./notify/push-relay.js');
76
78
  const { createAsksStore } = require('./notify/asks.js');
77
79
  const { createNotifier } = require('./notify/notifier.js');
80
+ const { createAskAlertRegistry } = require('./notify/ask-alert-registry.js');
81
+ const { canonicalAskAlert } = require('./notify/ask-alert-identity.js');
78
82
  const { createEventFeed } = require('./notify/event-feed.js');
79
83
  const { createEventFeedHistory } = require('./notify/event-feed-history.js');
80
84
  const { buildEnvelope } = require('./notify/event-feed-producers.js');
@@ -85,6 +89,7 @@ const { createAskReceipts } = require('./notify/ask-receipts.js');
85
89
  const { createAskAnswerService } = require('./notify/ask-answer-service.js');
86
90
  const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
87
91
  const { createAskRelay } = require('./notify/ask-relay.js');
92
+ const { createImportedDismissalDrainer } = require('./notify/imported-dismissals.js');
88
93
  const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
89
94
  const { readFederationEventsEnabled } = require('./config.js');
90
95
 
@@ -195,18 +200,21 @@ function createServer(opts = {}) {
195
200
  // alert budget and a durable (owner, eventId) registry — an event already
196
201
  // alerted never rings twice, across a replay or a restart. It is fed only by
197
202
  // the import hub below, so a local emission never takes this path.
203
+ const askAlertRegistry = createAskAlertRegistry({ filePath: path.join(notifyDir, 'ask-alerts.json') });
198
204
  const pushRelay = createPushRelay({
205
+ alertRegistry: askAlertRegistry,
199
206
  pushDedup: createPushDedup({ filePath: path.join(notifyDir, 'push-relay-dedup.json') }),
200
207
  send: (payload) => pushSvc.sendToAll(payload),
201
208
  log: opts.log || console.log,
202
209
  });
203
210
  const asksStore = createAsksStore({ dir: notifyDir });
211
+ const ownerAskScope = createOwnerAskScope(cfg);
204
212
  // --- Event feed (owner side) -------------------------------------------------
205
213
  // The notifier stays the single emission point; the feed is a SIDE EFFECT of
206
214
  // a local emission, never a separate path. Federated ingress and feed import
207
215
  // call deliverOnly(): local delivery that does NOT re-enter the publisher —
208
216
  // an imported event can never be re-exported, so A→B→A dies by construction.
209
- const notifierBase = createNotifier({ hub: eventsHub, push: pushSvc });
217
+ const notifierBase = createNotifier({ hub: eventsHub, push: pushSvc, alertRegistry: askAlertRegistry });
210
218
  const feedHistory = createEventFeedHistory({
211
219
  filePath: path.join(notifyDir, 'event-feed-history.json'),
212
220
  });
@@ -264,12 +272,17 @@ function createServer(opts = {}) {
264
272
  // arriva da un altro nodo. Il fan-out e' lo stesso dell'andata e NON parte
265
273
  // per un ask importato: la chiusura di quello la decide il suo owner.
266
274
  onClosure: (kind, info) => {
267
- try { notifier.emitRaw({ type: kind, id: info.askId }); } catch (_) {}
275
+ try {
276
+ const ask = asksStore.get(info.askId);
277
+ const ownerAskTs = ask && (ask.originNode ? ask.ownerAskTs : ask.ts);
278
+ notifier.emitRaw({ type: kind, id: info.askId,
279
+ ...(Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? { ownerAskTs } : {}) });
280
+ } catch (_) {}
268
281
  try {
269
282
  const ask = asksStore.get(info.askId);
270
283
  if (ask && !ask.originNode && closureFanout) {
271
284
  return closureFanout.dispatch({
272
- askId: info.askId,
285
+ askId: info.askId, ownerAskTs: ask.ts,
273
286
  outcome: kind === 'ask-dismissed' ? 'dismissed' : 'answered',
274
287
  session: (ask && ask.session) || info.cellSession,
275
288
  });
@@ -368,21 +381,21 @@ function createServer(opts = {}) {
368
381
  try {
369
382
  if (frame && frame.type === 'ask' && frame.ask) {
370
383
  const ask = frame.ask;
371
- const cellId = cellIdFromTmuxSession(ask.session);
384
+ const cellId = ownerAskScope.cellForSession(ask.session);
372
385
  if (cellId) {
373
386
  recordAndPublish({
374
387
  producer: 'ask', cellId,
375
- payload: { askId: ask.id, revision: ask.revision || 0, question: ask.question, options: ask.options, session: ask.session, ts: Date.now() },
388
+ payload: { askId: ask.id, revision: ask.revision || 0, question: ask.question, options: ask.options, session: ask.session, askTs: ask.ts, ts: Date.now() },
376
389
  });
377
390
  }
378
391
  } else if (frame && (frame.type === 'ask-answered' || frame.type === 'ask-dismissed')) {
379
392
  const all = asksStore.list({ open: false }).concat(asksStore.list({ open: true }));
380
393
  const ask = all.find((a) => a && a.id === frame.id);
381
- const cellId = ask ? cellIdFromTmuxSession(ask.session) : null;
394
+ const cellId = ask ? ownerAskScope.cellForSession(ask.session) : null;
382
395
  if (cellId) {
383
396
  recordAndPublish({
384
397
  producer: 'ask-closed', cellId,
385
- payload: { askId: frame.id, revision: (ask && ask.revision) || 0, outcome: frame.type === 'ask-dismissed' ? 'dismissed' : 'answered', ts: Date.now() },
398
+ payload: { askId: frame.id, askTs: ask.ts, revision: (ask && ask.revision) || 0, outcome: frame.type === 'ask-dismissed' ? 'dismissed' : 'answered', ts: Date.now() },
386
399
  });
387
400
  }
388
401
  }
@@ -392,7 +405,23 @@ function createServer(opts = {}) {
392
405
  // Delivery-only: federated ingress and feed import land here. NO history,
393
406
  // NO publisher — an imported event is never re-exported.
394
407
  async deliverOnly(frame) { return notifierBase.emit(frame); },
395
- deliverOnlyRaw(frame) { return notifierBase.emitRaw(frame); },
408
+ deliverOnlyRaw(frame) {
409
+ if (frame.type === 'ask' && frame.ask && frame.ask.ownerId) {
410
+ eventFeedClient.rememberLiveAsk(frame.ask.ownerId, frame.ask);
411
+ }
412
+ // An authenticated owner closure must reach the SAME exported view the
413
+ // live ask entered, even when the store alias was already closed or
414
+ // absent: a fresh page rebuilds its cards from that view. scope:'local'
415
+ // is a local dismissal, not the owner speaking — it keeps its own path.
416
+ if ((frame.type === 'ask-answered' || frame.type === 'ask-dismissed')
417
+ && frame.ownerId && frame.ownerAskId && frame.scope !== 'local') {
418
+ eventFeedClient.applyOwnerClosure(frame.ownerId, frame.ownerAskId, {
419
+ ownerAskTs: Number.isSafeInteger(frame.ownerAskTs) && frame.ownerAskTs > 0 ? frame.ownerAskTs : undefined,
420
+ outcome: frame.type === 'ask-answered' ? 'answered' : 'dismissed',
421
+ });
422
+ }
423
+ return notifierBase.emitRaw(frame);
424
+ },
396
425
  publishState: (producer, payload) => recordAndPublish({ producer, cellId: null, payload }),
397
426
  };
398
427
  const diagnostics = opts.diagnostics || createDiagnostics();
@@ -999,13 +1028,24 @@ function createServer(opts = {}) {
999
1028
  cellForSession: (session) => cellIdFromTmuxSession(session),
1000
1029
  resolveOrigin: (req) => cellScopeOrigin.resolve(req, { requireCell: false }),
1001
1030
  }));
1031
+ // UNO spawn per giro. La list-panes -a del fleet snapshot porta
1032
+ // sessioni E supervisori insieme (lib/tmux/list.js), e lo snapshot TMUX
1033
+ // viene coalescato entro la finestra (2 s): letture vicine di finestre o
1034
+ // client diversi condividono lo stesso giro, chi arriva mentre il giro e'
1035
+ // in volo aspetta quello. Oltre la finestra si rilegge. L'ENRICHMENT
1036
+ // (outbox, preview, telemetria, attivita) resta fuori dalla cache: sono
1037
+ // file che cambiano senza toccare tmux, e restano freschi a ogni lettura.
1038
+ const tmuxReads = createTmuxReadWindow(async () => {
1039
+ const panesRaw = await listFleetPanes(cfg.tmuxBin);
1040
+ const sessions = sessionsFromPanes(panesRaw);
1041
+ // La stessa lettura dice chi ha ancora il suo supervisore: campi 0-3
1042
+ // delle stesse righe (parsePanes), nessuna seconda chiamata.
1043
+ const statiSupervisore = sessions.length ? statiSupervisoreDi(panesRaw) : new Map();
1044
+ return { sessions, statiSupervisore };
1045
+ }, cfg.sessionsCacheMs);
1002
1046
  api.get('/sessions', async (_req, res) => {
1003
1047
  try {
1004
- const sessions = await listSessions(cfg.tmuxBin);
1005
- // UNA chiamata per giro per sapere chi ha ancora il suo supervisore.
1006
- const statiSupervisore = sessions.length
1007
- ? statiSupervisoreDi(await listPanes(cfg.tmuxBin))
1008
- : new Map();
1048
+ const { sessions, statiSupervisore } = await tmuxReads.read();
1009
1049
  const sum = watcher.getSummary();
1010
1050
  const enriched = await Promise.all(sessions.map(async (s) => {
1011
1051
  const sample = await previews.getState(s.name);
@@ -1224,7 +1264,8 @@ function createServer(opts = {}) {
1224
1264
  try {
1225
1265
  const closed = asksStore.closeImported({ ownerId, ownerAskId: alias.ownerAskId, outcome: 'dismissed' });
1226
1266
  if (closed.changed && closed.ask) {
1227
- try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId }); } catch (_) {}
1267
+ try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId, ownerAskId: closed.ask.ownerAskId,
1268
+ ...(Number.isSafeInteger(closed.ask.ownerAskTs) && closed.ask.ownerAskTs > 0 ? { ownerAskTs: closed.ask.ownerAskTs } : {}) }); } catch (_) {}
1228
1269
  }
1229
1270
  } catch (_) { /* un alias non chiudibile non ferma gli altri */ }
1230
1271
  }
@@ -1238,8 +1279,8 @@ function createServer(opts = {}) {
1238
1279
  // verso quelli. Senza, ogni ritentativo rimanderebbe la chiusura anche a chi
1239
1280
  // l'ha gia' ricevuta — rumore, non danno, ma nemmeno una coda che sa cosa
1240
1281
  // le manca.
1241
- run: ({ askId, outcome, session, targets }) => (closureFanout
1242
- ? closureFanout.dispatch({ askId, outcome, session, retryOnFailure: false, targets })
1282
+ run: ({ askId, ownerAskTs, outcome, session, targets }) => (closureFanout
1283
+ ? closureFanout.dispatch({ askId, ownerAskTs, outcome, session, retryOnFailure: false, targets })
1243
1284
  : Promise.resolve([])),
1244
1285
  });
1245
1286
  closureFanout = createClosureFanout({
@@ -1293,7 +1334,7 @@ function createServer(opts = {}) {
1293
1334
  asks: asksStore,
1294
1335
  answerService: askAnswerService,
1295
1336
  receipts: askReceipts,
1296
- cellForSession: (session) => cellIdFromTmuxSession(session),
1337
+ cellForSession: ownerAskScope.cellForSession,
1297
1338
  readonly: proxyReadonly,
1298
1339
  }));
1299
1340
  api.use('/event-feed', createEventFeedRoutes({
@@ -1307,7 +1348,8 @@ function createServer(opts = {}) {
1307
1348
  history: feedHistory,
1308
1349
  asksStore,
1309
1350
  fleetP,
1310
- cellForSession: (session) => cellIdFromTmuxSession(session),
1351
+ cellForSession: ownerAskScope.cellForSession,
1352
+ askScopeResolver: ownerAskScope.snapshotResolver,
1311
1353
  eventsEnabled,
1312
1354
  // Il produttore dello snapshot logga la lettura fallita degli asks: senza
1313
1355
  // questo wiring la riga finiva nel noop di default delle routes.
@@ -1321,6 +1363,7 @@ function createServer(opts = {}) {
1321
1363
  // resolves the owner from the authorized store and forwards with the peer's
1322
1364
  // own pair token. Uncertain outcomes are parked, never retried silently.
1323
1365
  const askRelay = createAskRelay({
1366
+ availabilityBinding: dismissalBinding,
1324
1367
  nodesPath, slotVerifier: askReverseVerifier,
1325
1368
  loadStore: () => nodesStore.loadStore(nodesPath),
1326
1369
  peers: federatedPeers,
@@ -1334,9 +1377,94 @@ function createServer(opts = {}) {
1334
1377
  // 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
1335
1378
  // federata non ha mai potuto attraversare, e nessun test copriva questa
1336
1379
  // rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
1380
+ function dismissalBinding(ownerId) {
1381
+ const st = nodesStore.loadStore(nodesPath);
1382
+ if (!st) return null;
1383
+ const direct = st.nodes.filter(node => node.nodeId === ownerId);
1384
+ const cached = topologyCache.loadCache(topologyCachePath);
1385
+ const route = cached && cached.nodes.find(node => node.instanceId === ownerId)?.route;
1386
+ const relevant = direct.length ? direct : st.nodes.filter(node => route && route.includes(node.name));
1387
+ return require('node:crypto').createHash('sha256').update(JSON.stringify(relevant.map(node => ({ nodeId: node.nodeId, direction: node.direction,
1388
+ shared: node.shared, pairingRevision: node.pairingRevision || 0, localPort: node.localPort, reversePool: node.reversePool,
1389
+ configured: accessPresets.grantsOf(node).configured, grants: accessPresets.grantsOf(node).grants }))) + JSON.stringify(route || [])).digest('hex');
1390
+ }
1391
+ function dismissalPeer(ownerId) {
1392
+ const st = nodesStore.loadStore(nodesPath);
1393
+ if (!st) return null;
1394
+ const direct = st.nodes.filter(node => node.nodeId === ownerId);
1395
+ if (direct.length) return direct.length === 1 ? direct[0] : null;
1396
+ // A cached route identifies the first hop, never authorizes it or proves
1397
+ // an outage. The current local grants and channel are checked separately.
1398
+ const cached = topologyCache.loadCache(topologyCachePath);
1399
+ const routes = cached && cached.nodes.filter(node => node.instanceId === ownerId);
1400
+ if (!routes || routes.length !== 1) return null;
1401
+ const first = st.nodes.filter(node => node.name === routes[0].route[0]);
1402
+ return first.length === 1 ? first[0] : null;
1403
+ }
1404
+ function localDismissalEligibility(ownerId, askId) {
1405
+ const peer = dismissalPeer(ownerId);
1406
+ const storedAlias = asksStore.findImported(ownerId, askId);
1407
+ const previous = asksStore.getImportedDismissal(ownerId, askId);
1408
+ const view = eventFeedClient.state().views.find(item => item.ownerId === ownerId);
1409
+ const feedAsk = view && view.asks.find(item => (item.ownerAskId || item.id) === askId);
1410
+ const tsOf = item => { const ts = item && (Object.hasOwn(item, 'ownerAskTs') ? item.ownerAskTs : item.originNode ? null : item.ts); return Number.isSafeInteger(ts) && ts > 0 ? ts : null; };
1411
+ let ask = storedAlias || feedAsk, ambiguous = false;
1412
+ if (storedAlias && feedAsk) {
1413
+ const aliasTs = tsOf(storedAlias), feedTs = tsOf(feedAsk);
1414
+ const sameContent = asksStore.askFingerprint(storedAlias) === asksStore.askFingerprint(feedAsk);
1415
+ if (aliasTs !== null && feedTs !== null && aliasTs !== feedTs) {
1416
+ ask = feedTs > aliasTs ? feedAsk : storedAlias;
1417
+ // An older visible feed cannot be acknowledged through a newer closed
1418
+ // alias. Its current generation needs an authoritative refresh.
1419
+ if (ask === storedAlias && (storedAlias.dismissed || storedAlias.answered)) ambiguous = true;
1420
+ }
1421
+ else if (!sameContent) ambiguous = true;
1422
+ else ask = feedTs !== null && aliasTs === null ? feedAsk : storedAlias;
1423
+ }
1424
+ // Guards and alias mutation belong only to the selected generation.
1425
+ const alias = storedAlias && ask && asksStore.sameImportedGeneration(storedAlias, ask) ? storedAlias : null;
1426
+ const grants = peer && accessPresets.grantsOf(peer);
1427
+ const cellId = ask ? cellIdFromTmuxSession(ask.session) : previous && previous.cellId;
1428
+ const visible = !!((ask || previous) && cellId && grants && grants.configured && grants.grants.eventsAccess === true
1429
+ && eventFeedAcl.visibilityAllows(grants.grants, cellId));
1430
+ const blocked = !!((ask && ask.answered) || (alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
1431
+ || askRelay.state().some(attempt => attempt.ownerId === ownerId && attempt.askId === askId && ['sent', 'uncertain'].includes(attempt.state)));
1432
+ const slot = peer && peer.reversePool && peer.reversePool.slots && peer.reversePool.slots[peer.reversePool.activeSlot];
1433
+ const unavailable = !!(peer && peer.direction === 'inbound' && (!peer.shared || !slot || !Number.isInteger(slot.port)))
1434
+ || askRelay.channelState(ownerId).unavailable;
1435
+ return { peer, alias, previous, ask, grants, cellId, visible, blocked: blocked || ambiguous, unavailable, ambiguous };
1436
+ }
1337
1437
  api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
1338
1438
  try {
1339
1439
  const b = req.body || {};
1440
+ if (b.action === 'dismiss-local') {
1441
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: mutation blocked' });
1442
+ if (req.headers[HOP_HEADER]) return res.status(403).json({ error: 'local operator required' });
1443
+ if (!b || Array.isArray(b) || Object.keys(b).some(key => !['action', 'ownerId', 'askId'].includes(key))
1444
+ || !/^[a-f0-9]{32}$/.test(b.ownerId) || !/^[a-f0-9]{8}$/.test(b.askId) || b.ownerId === federatedNodeId()) {
1445
+ return res.status(400).json({ error: 'invalid local dismissal body' });
1446
+ }
1447
+ try { if (!asksStore.health().readable) return res.status(503).json({ error: 'dismissal store unavailable' }); }
1448
+ catch (_) { return res.status(503).json({ error: 'dismissal store unavailable' }); }
1449
+ const { peer, alias, previous, ask, grants, cellId, visible, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
1450
+ if (!visible) return res.status(404).json({ error: 'unknown or invisible ask' });
1451
+ if (ambiguous) return res.status(409).json({ error: 'ask generation is ambiguous; refresh the owner view', reason: 'generation-ambiguous' });
1452
+ if (ask && ask.answered) return res.status(409).json({ error: 'ask already answered', reason: 'answered' });
1453
+ if ((alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
1454
+ || askRelay.state().some(attempt => attempt.ownerId === b.ownerId && attempt.askId === b.askId && ['sent', 'uncertain'].includes(attempt.state))) {
1455
+ return res.status(409).json({ error: 'answer requires reconciliation', reason: 'delivery-unknown-block' });
1456
+ }
1457
+ if (!ask && previous) return res.json({ dismissed: true, scope: 'local', ownerSync: previous.syncState === 'blocked' ? 'blocked' : 'pending' });
1458
+ if (!unavailable) return res.status(409).json({ error: 'owner channel is not known unavailable', reason: 'owner-availability-unknown' });
1459
+ const out = asksStore.dismissImported({ ownerId: b.ownerId, ownerAskId: b.askId, ask, cellId, retryBinding: dismissalBinding(b.ownerId),
1460
+ syncState: grants.grants.askReplyAccess === true ? 'pending' : 'blocked' });
1461
+ if (!out.ok) return res.status(out.reason === 'dismissal-cap' ? 429 : 503).json({ error: 'local dismissal refused', reason: out.reason });
1462
+ notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: alias ? alias.id : b.askId, ownerId: b.ownerId, ownerAskId: b.askId, scope: 'local',
1463
+ // UI-only context also distinguishes changed content before the first snapshot.
1464
+ askGeneration: { question: ask.question, options: ask.options || [], session: ask.session },
1465
+ ownerAskTs: out.record.ownerAskTs, ownerSync: out.record.syncState === 'blocked' ? 'blocked' : 'pending' });
1466
+ return res.json({ dismissed: true, scope: 'local', ownerSync: out.record.syncState === 'blocked' ? 'blocked' : 'pending' });
1467
+ }
1340
1468
  if (b.action === 'dismiss') {
1341
1469
  const out = await askRelay.relayDismiss({ ownerId: b.ownerId, askId: b.askId });
1342
1470
  // Dismiss CONFERMATO dall'owner (2xx): la view del feed lo dimentica
@@ -1367,7 +1495,16 @@ function createServer(opts = {}) {
1367
1495
  const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
1368
1496
  const askId = typeof req.query.askId === 'string' ? req.query.askId : '';
1369
1497
  if (!/^[a-f0-9]{32}$/.test(ownerId) || !/^[a-f0-9]{8}$/.test(askId)) return res.status(400).json({ error: 'ownerId e askId richiesti' });
1370
- try { res.json(await askRelay.replyCapability({ ownerId, askId })); }
1498
+ try {
1499
+ const remote = await askRelay.replyCapability({ ownerId, askId });
1500
+ if (req.query.dismissals !== '1') return res.json(remote);
1501
+ const local = localDismissalEligibility(ownerId, askId);
1502
+ const mutable = !proxyReadonly() && !req.headers[HOP_HEADER];
1503
+ let readable = false;
1504
+ try { readable = asksStore.health().readable; } catch (_) {}
1505
+ res.json({ ...remote, canDismissRemote: mutable && remote.canReply === true,
1506
+ canDismissLocal: mutable && readable && local.visible && !local.blocked && (local.unavailable || !!local.previous) });
1507
+ }
1371
1508
  catch (_) { res.json({ ownerId, askId, canReply: false, status: 'unreachable' }); }
1372
1509
  });
1373
1510
  api.get('/asks-relay/state', (_req, res) => {
@@ -1381,7 +1518,12 @@ function createServer(opts = {}) {
1381
1518
  // durable write.
1382
1519
  const importHub = {
1383
1520
  broadcast: (event) => {
1384
- eventsHub.broadcast(event);
1521
+ const alert = event && event.type === 'notify' && canonicalAskAlert(event);
1522
+ const claim = alert && !bridgeReadonly() ? askAlertRegistry.claim(event, 'ui') : { allowed: true };
1523
+ if (claim.allowed) {
1524
+ const count = eventsHub.broadcast(event);
1525
+ if (alert && !bridgeReadonly()) askAlertRegistry.complete(event, 'ui', count);
1526
+ }
1385
1527
  if (!bridgeReadonly()) pushRelay.considerImported(event);
1386
1528
  },
1387
1529
  };
@@ -1392,6 +1534,12 @@ function createServer(opts = {}) {
1392
1534
  }
1393
1535
  }
1394
1536
  const eventFeedClient = createEventFeedClient({
1537
+ onViewChanged: ownerId => eventsHub.broadcast({ type: 'feed-state-changed', ownerId }),
1538
+ onAuthoritativeAsks: (ownerId, asks) => {
1539
+ if (!bridgeReadonly()) for (const ask of asks) askAlertRegistry.adopt(ownerId, ask, ask.ownerAskTs === undefined ? ask.ts : ask.ownerAskTs);
1540
+ },
1541
+ isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
1542
+ onAskClosed: (ownerId, ownerAskId, outcome, ownerAskTs) => asksStore.closeImported({ ownerId, ownerAskId, outcome, ownerAskTs }),
1395
1543
  loadStore: () => nodesStore.loadStore(nodesPath),
1396
1544
  token: () => tokenHolder.value,
1397
1545
  eventsHub: importHub,
@@ -1404,6 +1552,11 @@ function createServer(opts = {}) {
1404
1552
  pollMs: cfg.eventFeedClientPollMs,
1405
1553
  });
1406
1554
  eventFeedClient.start();
1555
+ const importedDismissalDrainer = createImportedDismissalDrainer({
1556
+ store: asksStore, relay: askRelay, readonly: proxyReadonly, log: opts.log || console.log,
1557
+ bindingForOwner: dismissalBinding,
1558
+ });
1559
+
1407
1560
  // Local aggregated read for the UI: connected views only, with the cursor
1408
1561
  // watermark so the first fetch cannot race the live stream. Local
1409
1562
  // authenticated route, NOT in the federation allowlist.
@@ -1617,6 +1770,7 @@ function createServer(opts = {}) {
1617
1770
  });
1618
1771
  // Store-root revision of the peer access policy: the CAS value the UI
1619
1772
  // must send back with every preset write (stale revision -> 409).
1773
+ importedDismissalDrainer.nudge();
1620
1774
  res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
1621
1775
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1622
1776
  });
@@ -1748,13 +1902,14 @@ function createServer(opts = {}) {
1748
1902
  diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
1749
1903
  port: runtimePort(), platform: cfg.platform || process.platform,
1750
1904
  });
1751
- updater.start(); startManagedTunnels();
1905
+ updater.start(); startManagedTunnels(); importedDismissalDrainer.start();
1752
1906
  });
1753
1907
  server.on('close', () => {
1754
1908
  diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
1755
1909
  watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
1756
1910
  // Nessun timer di ritentativo sopravvive alla chiusura del server.
1757
1911
  if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
1912
+ importedDismissalDrainer.stop(); eventFeedClient.stop();
1758
1913
  for (const timer of reverseWatchers.values()) clearInterval(timer);
1759
1914
  reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
1760
1915
  // Il pannello non sopravvive al control plane: senza requireToken sopra,
package/lib/tmux/list.js CHANGED
@@ -108,6 +108,50 @@ function listSessions(tmuxBin = 'tmux') {
108
108
  });
109
109
  }
110
110
 
111
+ // Il formato "fleet snapshot": i campi del supervisore di PANES_FMT (primi
112
+ // quattro, gli stessi che legge parsePanes) SEGUITI dai campi della sessione.
113
+ // UNA sola chiamata list-panes -a alimenta sia lo stato dei supervisori sia
114
+ // l'inventario sessioni di /api/sessions: due spawn per giro diventano uno.
115
+ // L'ordine dei campi sessione rispecchia FMT (name, attached, windows,
116
+ // created, activity, cmd, visibility, titolo), con i due marker del pane
117
+ // attivo prima del titolo.
118
+ const FLEET_PANES_FMT = `${PANES_FMT}\t#{session_attached}\t#{session_windows}\t#{session_created}\t#{session_activity}\t#{pane_current_command}\t#{@nexuscrew_visibility}\t#{window_active}\t#{pane_active}\t#{pane_title}`;
119
+
120
+ // Dalle righe per-pane alle righe per-SESSIONE: per ogni nome tiene il pane
121
+ // ATTIVO (window_active=1 e pane_active=1) — lo stesso pane a cui si
122
+ // riferivano i formati pane_* di list-sessions. I campi sessione sono uguali
123
+ // su tutte le righe dello stesso nome; cambia solo quale pane parla, e deve
124
+ // parlare quello attivo.
125
+ function sessionsFromPanes(raw) {
126
+ const rows = new Map();
127
+ for (const line of String(raw).split('\n')) {
128
+ if (!line.trim()) continue;
129
+ const f = line.split('\t');
130
+ const name = f[0];
131
+ if (!name) continue;
132
+ const attiva = f[10] === '1' && f[11] === '1';
133
+ const previous = rows.get(name);
134
+ if (previous && (!attiva || previous.attiva)) continue;
135
+ rows.set(name, {
136
+ attiva,
137
+ line: [f[0], f[4], f[5], f[6], f[7], f[8], f[9], f.slice(12).join('\t')].join('\t'),
138
+ });
139
+ }
140
+ return parseSessions([...rows.values()].map((r) => r.line).join('\n'));
141
+ }
142
+
143
+ function listFleetPanes(tmuxBin = 'tmux') {
144
+ return new Promise((resolve, reject) => {
145
+ execFile(tmuxBin, ['list-panes', '-a', '-F', FLEET_PANES_FMT], (err, stdout, stderr) => {
146
+ if (err) {
147
+ if (isNoTmuxServerError(stderr || err.message)) return resolve('');
148
+ return reject(new Error(`tmux list-panes failed: ${stderr || err.message}`));
149
+ }
150
+ resolve(stdout);
151
+ });
152
+ });
153
+ }
154
+
111
155
  // How many clients are already attached to this session (before our attach).
112
156
  // Used to pick a sane resize default: drive the size only when no one else is watching.
113
157
  function attachedClients(tmuxBin = 'tmux', session) {
@@ -123,4 +167,5 @@ function attachedClients(tmuxBin = 'tmux', session) {
123
167
 
124
168
  module.exports = {
125
169
  parsePaneTitle, parseSessions, isNoTmuxServerError, listSessions, listPanes, attachedClients, setSessionVisibility, FMT,
170
+ FLEET_PANES_FMT, sessionsFromPanes, listFleetPanes,
126
171
  };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ // Coalescing della risposta di /api/sessions (finestra di 2 s, decisione
4
+ // operativa: latenza percepita al piu' pari alla finestra in cambio di spawn
5
+ // dimezzati — anzi, di UNO spawn per finestra condiviso fra client).
6
+ //
7
+ // Tre comportamenti, tutti voluti:
8
+ // - letture entro la finestra servono la risposta gia' pronta: zero spawn;
9
+ // - letture che arrivano mentre il giro e' in volo aspettano QUELLO giro
10
+ // (mai due serie di spawn contemporanee per lo stesso endpoint);
11
+ // - un errore non si cache-a: il giro fallito non lascia nulla e la
12
+ // prossima lettura riparte da zero.
13
+ //
14
+ // La finestra e' breve PERCHE' la cache non deve nascondere i cambi di tmux:
15
+ // kill, avvio e cambio finestra diventano visibili al piu' tardi al primo
16
+ // giro dopo la finestra. Il test di freschezza custodisce esattamente questo.
17
+
18
+ // Finestra di coalescing in millisecondi. Il valore e' un contratto
19
+ // operativo (decisione approvata): al piu' 2 s di latenza aggiuntiva sulla
20
+ // vista sessioni, in cambio di una serie sola di spawn per finestra.
21
+ const TMUX_READS_WINDOW_MS = 2000;
22
+
23
+ function createTmuxReadWindow(loader, windowMs = TMUX_READS_WINDOW_MS) {
24
+ let cached = null;
25
+ let inFlight = null;
26
+ return {
27
+ async read() {
28
+ if (cached && Date.now() - cached.at < windowMs) return cached.value;
29
+ if (inFlight) return inFlight;
30
+ inFlight = (async () => {
31
+ try {
32
+ const value = await loader();
33
+ cached = { at: Date.now(), value };
34
+ return value;
35
+ } finally {
36
+ inFlight = null;
37
+ }
38
+ })();
39
+ return inFlight;
40
+ },
41
+ };
42
+ }
43
+
44
+ module.exports = { TMUX_READS_WINDOW_MS, createTmuxReadWindow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.58",
3
+ "version": "0.9.60",
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": {