@mmmbuto/nexuscrew 0.9.59 → 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.
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');
@@ -196,18 +200,21 @@ function createServer(opts = {}) {
196
200
  // alert budget and a durable (owner, eventId) registry — an event already
197
201
  // alerted never rings twice, across a replay or a restart. It is fed only by
198
202
  // the import hub below, so a local emission never takes this path.
203
+ const askAlertRegistry = createAskAlertRegistry({ filePath: path.join(notifyDir, 'ask-alerts.json') });
199
204
  const pushRelay = createPushRelay({
205
+ alertRegistry: askAlertRegistry,
200
206
  pushDedup: createPushDedup({ filePath: path.join(notifyDir, 'push-relay-dedup.json') }),
201
207
  send: (payload) => pushSvc.sendToAll(payload),
202
208
  log: opts.log || console.log,
203
209
  });
204
210
  const asksStore = createAsksStore({ dir: notifyDir });
211
+ const ownerAskScope = createOwnerAskScope(cfg);
205
212
  // --- Event feed (owner side) -------------------------------------------------
206
213
  // The notifier stays the single emission point; the feed is a SIDE EFFECT of
207
214
  // a local emission, never a separate path. Federated ingress and feed import
208
215
  // call deliverOnly(): local delivery that does NOT re-enter the publisher —
209
216
  // an imported event can never be re-exported, so A→B→A dies by construction.
210
- const notifierBase = createNotifier({ hub: eventsHub, push: pushSvc });
217
+ const notifierBase = createNotifier({ hub: eventsHub, push: pushSvc, alertRegistry: askAlertRegistry });
211
218
  const feedHistory = createEventFeedHistory({
212
219
  filePath: path.join(notifyDir, 'event-feed-history.json'),
213
220
  });
@@ -265,12 +272,17 @@ function createServer(opts = {}) {
265
272
  // arriva da un altro nodo. Il fan-out e' lo stesso dell'andata e NON parte
266
273
  // per un ask importato: la chiusura di quello la decide il suo owner.
267
274
  onClosure: (kind, info) => {
268
- 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 (_) {}
269
281
  try {
270
282
  const ask = asksStore.get(info.askId);
271
283
  if (ask && !ask.originNode && closureFanout) {
272
284
  return closureFanout.dispatch({
273
- askId: info.askId,
285
+ askId: info.askId, ownerAskTs: ask.ts,
274
286
  outcome: kind === 'ask-dismissed' ? 'dismissed' : 'answered',
275
287
  session: (ask && ask.session) || info.cellSession,
276
288
  });
@@ -369,7 +381,7 @@ function createServer(opts = {}) {
369
381
  try {
370
382
  if (frame && frame.type === 'ask' && frame.ask) {
371
383
  const ask = frame.ask;
372
- const cellId = cellIdFromTmuxSession(ask.session);
384
+ const cellId = ownerAskScope.cellForSession(ask.session);
373
385
  if (cellId) {
374
386
  recordAndPublish({
375
387
  producer: 'ask', cellId,
@@ -379,11 +391,11 @@ function createServer(opts = {}) {
379
391
  } else if (frame && (frame.type === 'ask-answered' || frame.type === 'ask-dismissed')) {
380
392
  const all = asksStore.list({ open: false }).concat(asksStore.list({ open: true }));
381
393
  const ask = all.find((a) => a && a.id === frame.id);
382
- const cellId = ask ? cellIdFromTmuxSession(ask.session) : null;
394
+ const cellId = ask ? ownerAskScope.cellForSession(ask.session) : null;
383
395
  if (cellId) {
384
396
  recordAndPublish({
385
397
  producer: 'ask-closed', cellId,
386
- 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() },
387
399
  });
388
400
  }
389
401
  }
@@ -393,7 +405,23 @@ function createServer(opts = {}) {
393
405
  // Delivery-only: federated ingress and feed import land here. NO history,
394
406
  // NO publisher — an imported event is never re-exported.
395
407
  async deliverOnly(frame) { return notifierBase.emit(frame); },
396
- 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
+ },
397
425
  publishState: (producer, payload) => recordAndPublish({ producer, cellId: null, payload }),
398
426
  };
399
427
  const diagnostics = opts.diagnostics || createDiagnostics();
@@ -1000,13 +1028,24 @@ function createServer(opts = {}) {
1000
1028
  cellForSession: (session) => cellIdFromTmuxSession(session),
1001
1029
  resolveOrigin: (req) => cellScopeOrigin.resolve(req, { requireCell: false }),
1002
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);
1003
1046
  api.get('/sessions', async (_req, res) => {
1004
1047
  try {
1005
- const sessions = await listSessions(cfg.tmuxBin);
1006
- // UNA chiamata per giro per sapere chi ha ancora il suo supervisore.
1007
- const statiSupervisore = sessions.length
1008
- ? statiSupervisoreDi(await listPanes(cfg.tmuxBin))
1009
- : new Map();
1048
+ const { sessions, statiSupervisore } = await tmuxReads.read();
1010
1049
  const sum = watcher.getSummary();
1011
1050
  const enriched = await Promise.all(sessions.map(async (s) => {
1012
1051
  const sample = await previews.getState(s.name);
@@ -1225,7 +1264,8 @@ function createServer(opts = {}) {
1225
1264
  try {
1226
1265
  const closed = asksStore.closeImported({ ownerId, ownerAskId: alias.ownerAskId, outcome: 'dismissed' });
1227
1266
  if (closed.changed && closed.ask) {
1228
- try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId, ownerAskId: closed.ask.ownerAskId }); } 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 (_) {}
1229
1269
  }
1230
1270
  } catch (_) { /* un alias non chiudibile non ferma gli altri */ }
1231
1271
  }
@@ -1239,8 +1279,8 @@ function createServer(opts = {}) {
1239
1279
  // verso quelli. Senza, ogni ritentativo rimanderebbe la chiusura anche a chi
1240
1280
  // l'ha gia' ricevuta — rumore, non danno, ma nemmeno una coda che sa cosa
1241
1281
  // le manca.
1242
- run: ({ askId, outcome, session, targets }) => (closureFanout
1243
- ? 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 })
1244
1284
  : Promise.resolve([])),
1245
1285
  });
1246
1286
  closureFanout = createClosureFanout({
@@ -1294,7 +1334,7 @@ function createServer(opts = {}) {
1294
1334
  asks: asksStore,
1295
1335
  answerService: askAnswerService,
1296
1336
  receipts: askReceipts,
1297
- cellForSession: (session) => cellIdFromTmuxSession(session),
1337
+ cellForSession: ownerAskScope.cellForSession,
1298
1338
  readonly: proxyReadonly,
1299
1339
  }));
1300
1340
  api.use('/event-feed', createEventFeedRoutes({
@@ -1308,7 +1348,8 @@ function createServer(opts = {}) {
1308
1348
  history: feedHistory,
1309
1349
  asksStore,
1310
1350
  fleetP,
1311
- cellForSession: (session) => cellIdFromTmuxSession(session),
1351
+ cellForSession: ownerAskScope.cellForSession,
1352
+ askScopeResolver: ownerAskScope.snapshotResolver,
1312
1353
  eventsEnabled,
1313
1354
  // Il produttore dello snapshot logga la lettura fallita degli asks: senza
1314
1355
  // questo wiring la riga finiva nel noop di default delle routes.
@@ -1477,7 +1518,12 @@ function createServer(opts = {}) {
1477
1518
  // durable write.
1478
1519
  const importHub = {
1479
1520
  broadcast: (event) => {
1480
- 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
+ }
1481
1527
  if (!bridgeReadonly()) pushRelay.considerImported(event);
1482
1528
  },
1483
1529
  };
@@ -1488,8 +1534,12 @@ function createServer(opts = {}) {
1488
1534
  }
1489
1535
  }
1490
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
+ },
1491
1541
  isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
1492
- onAskClosed: (ownerId, ownerAskId, outcome) => asksStore.closeImported({ ownerId, ownerAskId, outcome }),
1542
+ onAskClosed: (ownerId, ownerAskId, outcome, ownerAskTs) => asksStore.closeImported({ ownerId, ownerAskId, outcome, ownerAskTs }),
1493
1543
  loadStore: () => nodesStore.loadStore(nodesPath),
1494
1544
  token: () => tokenHolder.value,
1495
1545
  eventsHub: importHub,
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.59",
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": {