@mmmbuto/nexuscrew 0.9.40 → 0.9.42

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
@@ -8,7 +8,8 @@ const express = require('express');
8
8
  const { WebSocketServer } = require('ws');
9
9
  const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
10
10
  const { writeConfigAtomic } = require('./cli/init.js');
11
- const { listSessions, attachedClients, setSessionVisibility } = require('./tmux/list.js');
11
+ const { listSessions, listPanes, attachedClients, setSessionVisibility } = require('./tmux/list.js');
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');
14
15
  const { createPreviewSampler } = require('./tmux/preview.js');
@@ -20,6 +21,7 @@ const { requireToken, bearerFrom } = require('./auth/middleware.js');
20
21
  const { filesRoutes } = require('./files/routes.js');
21
22
  const { createOutboxWatcher } = require('./files/watcher.js');
22
23
  const { leggiTelemetria } = require('./files/telemetry.js');
24
+ const { leggiAttivita, leggiGenerazione } = require('./files/activity.js');
23
25
  const VERSION = require('../package.json').version;
24
26
  const { transcribe } = require('./voice/transcribe.js');
25
27
  const { selectProvider } = require('./fleet/provider.js');
@@ -975,6 +977,10 @@ function createServer(opts = {}) {
975
977
  api.get('/sessions', async (_req, res) => {
976
978
  try {
977
979
  const sessions = await listSessions(cfg.tmuxBin);
980
+ // UNA chiamata per giro per sapere chi ha ancora il suo supervisore.
981
+ const statiSupervisore = sessions.length
982
+ ? statiSupervisoreDi(await listPanes(cfg.tmuxBin))
983
+ : new Map();
978
984
  const sum = watcher.getSummary();
979
985
  const enriched = await Promise.all(sessions.map(async (s) => {
980
986
  const sample = await previews.getState(s.name);
@@ -982,6 +988,7 @@ function createServer(opts = {}) {
982
988
  // the capture fallback. This prevents a quoted "• Working (...)" line
983
989
  // inside another agent's transcript from becoming a false positive.
984
990
  const piTitle = /^(?:π|pi)(?:\s+-|$)/iu.test(s.paneTitle);
991
+ const statoDelSupervisore = statiSupervisore.get(s.name) || 'assente';
985
992
  const working = s.working === true || (piTitle && sample?.working === true);
986
993
  return {
987
994
  ...s,
@@ -993,6 +1000,33 @@ function createServer(opts = {}) {
993
1000
  // Claude via statusline). Null per tutte le altre — assenza
994
1001
  // legittima, non un trattino: la riga resta com'era.
995
1002
  telemetry: leggiTelemetria(cfg.filesRoot, s.name),
1003
+ // Stato del turno pubblicato dagli hook della cella: lavora /
1004
+ // ferma / attesa. `null` NON e' «ferma»: e' «non verificato» —
1005
+ // nessun evento entro la finestra, oppure una cella che non ha questo
1006
+ // canale (codex-vl, pi, shell). La UI non deve dedurre «ferma» da un
1007
+ // silenzio: e' esattamente l'errore che il titolo del pane fa oggi.
1008
+ //
1009
+ // E SI LEGGE SOLO SE IL SUPERVISORE DELLA CELLA ESISTE ANCORA. Con
1010
+ // `remain-on-exit` (runtime.js) la sessione sopravvive al suo
1011
+ // processo: un supervisore ucciso (SIGKILL, OOM) lascia la sessione
1012
+ // «attiva», e lo `Stop` con `exit:1` scritto prima resterebbe «ferma»
1013
+ // per sempre — mentre non c'e' nessun client a cui quello stato si
1014
+ // riferisca. La verita' e' il pane che esegue cell-exec, marcato al
1015
+ // lancio: `#{pane_dead}` della sessione riguarda il pane ATTIVO, e con
1016
+ // una seconda finestra viva selezionata direbbe «vivo» a supervisore
1017
+ // morto (misurato). La decisione completa sta in
1018
+ // `lib/tmux/supervisor-pane.js`: qui si passa lo stato del pane e — per
1019
+ // il solo caso «nessun marcatore» — il segno `exit:1` del file, che
1020
+ // distingue un lancio nuovo con marcatura fallita (non si legge: il
1021
+ // suo `Stop` non scadrebbe) da un lancio VECCHIO (si legge, e il
1022
+ // lettore fa scadere tutto a 5 minuti). E' quello che evita di
1023
+ // mostrare «non verificato» su ogni cella viva di ogni nodo appena
1024
+ // aggiornato.
1025
+ attivita: statoDelSupervisore === 'vivo'
1026
+ ? leggiAttivita(cfg.filesRoot, s.name)
1027
+ : (senzaSupervisore(statoDelSupervisore, leggiGenerazione(path.join(cfg.filesRoot, s.name)).uscitaGarantita)
1028
+ ? null
1029
+ : leggiAttivita(cfg.filesRoot, s.name)),
996
1030
  };
997
1031
  }));
998
1032
  res.json({ sessions: enriched });
@@ -1272,6 +1306,12 @@ function createServer(opts = {}) {
1272
1306
  });
1273
1307
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1274
1308
  });
1309
+ // Riconciliazione dopo refresh: gli esiti incerti noti al relay (solo
1310
+ // contatori/ID, mai testo) per ricostruire lo stato «verifica» senza reinvii.
1311
+ api.get('/asks-relay/state', (_req, res) => {
1312
+ try { res.json({ attempts: askRelay.state() }); }
1313
+ catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1314
+ });
1275
1315
  // The client re-emits imported events into the local hub (deliverOnly: the
1276
1316
  // notifier is bypassed on purpose). The alert relay hangs off that same call,
1277
1317
  // so ONLY an imported notify can ring this device, and never through the
@@ -68,9 +68,10 @@ const { scrubError } = require('../update/core.js');
68
68
  // il transaction handler di /nodes/pair e la superficie pubblica di peering.
69
69
  const { createPairHandler } = require('./pairing-coordinator.js');
70
70
  const { publicPeeringRoutes } = require('./public-peering-routes.js');
71
+ const aiDesktop = require('../fleet/ai-desktop.js');
71
72
 
72
73
  // Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task).
73
- const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate', 'alternateScreen']);
74
+ const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate', 'alternateScreen', 'aiDesktop']);
74
75
  const ROLE_KEYS = new Set(['client', 'node']);
75
76
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
76
77
  const EDIT_KEYS = new Set([
@@ -279,10 +280,10 @@ function settingsRoutes(deps = {}) {
279
280
  return send(res, 400, { error: 'body deve essere un oggetto JSON' });
280
281
  }
281
282
  for (const k of Object.keys(b)) {
282
- if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate, alternateScreen)` });
283
+ if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate, alternateScreen, aiDesktop)` });
283
284
  }
284
285
  if (Object.keys(b).length === 0) {
285
- return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate, alternateScreen)' });
286
+ return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate, alternateScreen, aiDesktop)' });
286
287
  }
287
288
  if (b.roles !== undefined) {
288
289
  if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
@@ -315,6 +316,9 @@ function settingsRoutes(deps = {}) {
315
316
  if (b.alternateScreen !== undefined && typeof b.alternateScreen !== 'boolean') {
316
317
  return send(res, 400, { error: 'alternateScreen deve essere boolean' });
317
318
  }
319
+ if (b.aiDesktop !== undefined && typeof b.aiDesktop !== 'boolean') {
320
+ return send(res, 400, { error: 'aiDesktop deve essere boolean' });
321
+ }
318
322
 
319
323
  // merge sul config esistente (preserva le chiavi non gestite qui)
320
324
  const { cfg: current } = readConfigFile(configPath);
@@ -327,6 +331,7 @@ function settingsRoutes(deps = {}) {
327
331
  if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
328
332
  if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
329
333
  if (b.alternateScreen !== undefined) next.alternateScreen = b.alternateScreen;
334
+ if (b.aiDesktop !== undefined) next.aiDesktop = b.aiDesktop;
330
335
  atomicWriteConfig(configPath, next);
331
336
  if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
332
337
  updater.setEnabled(b.autoUpdate);
@@ -353,6 +358,53 @@ function settingsRoutes(deps = {}) {
353
358
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
354
359
  });
355
360
 
361
+ // --- AIDesktop: la spunta che governa il container e il tasto --------------
362
+ // GET: la chiave esplicita comanda; se l'utente non l'ha mai toccata, il
363
+ // default si deriva dal container (in esecuzione → true). docker viene
364
+ // interrogato SOLO qui, su chiamata esplicita — mai nel percorso caldo del
365
+ // fleetStatus. Il binario passa dal seam per i test (finto binario).
366
+ r.get('/ai-desktop', async (req, res) => {
367
+ const { cfg: cfgNow } = readConfigFile(configPath);
368
+ const esplicita = aiDesktop.chiaveEsplicita(cfgNow);
369
+ const stato = await aiDesktop.containerRunning({ dockerBin: seams.aiDesktopDockerBin });
370
+ const desired = esplicita !== null ? esplicita : stato.running;
371
+ return send(res, 200, {
372
+ desired, running: stato.running, exists: stato.exists, explicit: esplicita !== null,
373
+ ...(stato.error ? { error: stato.error } : {}),
374
+ });
375
+ });
376
+
377
+ // POST {enabled}: salva la chiave ED esegue start o stop, restituendo
378
+ // l'esito VERO del comando — nessun successo ottimistico. Se docker
379
+ // risponde male la risposta è 500 con la causa: la chiave resta salvata
380
+ // (la spunta dice COSA si vuole, l'esito dice SE il mondo ci è arrivato)
381
+ // e il badge della UI mostra il disallineamento invece di fingere.
382
+ r.post('/ai-desktop', mutGate, async (req, res) => {
383
+ const b = req.body;
384
+ if (!b || typeof b !== 'object' || Array.isArray(b) || typeof b.enabled !== 'boolean') {
385
+ return send(res, 400, { error: 'body deve essere {enabled: boolean}' });
386
+ }
387
+ const { cfg: cfgNow } = readConfigFile(configPath);
388
+ try {
389
+ atomicWriteConfig(configPath, { ...cfgNow, aiDesktop: b.enabled });
390
+ } catch (e) {
391
+ return send(res, 500, { error: `config non salvata: ${e.message}` });
392
+ }
393
+ const dockerOpts = { dockerBin: seams.aiDesktopDockerBin };
394
+ const esito = b.enabled
395
+ ? await aiDesktop.avvia(dockerOpts)
396
+ : await aiDesktop.ferma(dockerOpts);
397
+ if (!esito.ok) {
398
+ return send(res, 500, {
399
+ error: esito.error || 'comando docker fallito',
400
+ desired: b.enabled,
401
+ running: esito.running ?? null,
402
+ timeout: !!esito.timeout,
403
+ });
404
+ }
405
+ return send(res, 200, { ok: true, desired: b.enabled, running: esito.running });
406
+ });
407
+
356
408
  // Auto-update npm: check e apply sono autenticati, READONLY-gated e non
357
409
  // accettano mai package/versione dal browser. Il manager usa esclusivamente
358
410
  // @mmmbuto/nexuscrew@latest, confronta semver e installa la versione esatta.
package/lib/tmux/list.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const { execFile, execFileSync } = require('node:child_process');
3
+ const { PANES_FMT } = require('./supervisor-pane.js');
3
4
 
4
5
  const FMT = "#{session_name}\t#{session_attached}\t#{session_windows}\t#{session_created}\t#{session_activity}\t#{pane_current_command}\t#{@nexuscrew_visibility}\t#{pane_title}";
5
6
 
@@ -78,6 +79,23 @@ function setSessionVisibility(tmuxBin = 'tmux', name, technical = false) {
78
79
  });
79
80
  }
80
81
 
82
+ // I pani di TUTTE le sessioni, in UNA chiamata: da qui si ricava quale pane e'
83
+ // il supervisore di ogni cella e se e' vivo (lib/tmux/supervisor-pane.js).
84
+ // `#{pane_dead}` qui e' quello giusto: il pane del SUPERVISORE, non il pane
85
+ // attivo della sessione — che con una seconda finestra viva direbbe «vivo»
86
+ // anche con il supervisore morto.
87
+ function listPanes(tmuxBin = 'tmux') {
88
+ return new Promise((resolve, reject) => {
89
+ execFile(tmuxBin, ['list-panes', '-a', '-F', PANES_FMT], (err, stdout, stderr) => {
90
+ if (err) {
91
+ if (isNoTmuxServerError(stderr || err.message)) return resolve('');
92
+ return reject(new Error(`tmux list-panes failed: ${stderr || err.message}`));
93
+ }
94
+ resolve(stdout);
95
+ });
96
+ });
97
+ }
98
+
81
99
  function listSessions(tmuxBin = 'tmux') {
82
100
  return new Promise((resolve, reject) => {
83
101
  execFile(tmuxBin, ['list-sessions', '-F', FMT], (err, stdout, stderr) => {
@@ -104,5 +122,5 @@ function attachedClients(tmuxBin = 'tmux', session) {
104
122
  }
105
123
 
106
124
  module.exports = {
107
- parsePaneTitle, parseSessions, isNoTmuxServerError, listSessions, attachedClients, setSessionVisibility, FMT,
125
+ parsePaneTitle, parseSessions, isNoTmuxServerError, listSessions, listPanes, attachedClients, setSessionVisibility, FMT,
108
126
  };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ // Chi e' il pane del SUPERVISORE di una cella, e se e' ancora vivo.
3
+ //
4
+ // PERCHE' NON BASTA `#{pane_dead}` DI list-sessions (misurato con tmux vero):
5
+ // quel campo riguarda il pane ATTIVO della sessione. Con una seconda finestra
6
+ // viva selezionata dice «vivo» anche quando il pane del supervisore e' morto, e
7
+ // allora lo `Stop` con `exit:1` scritto prima resta «ferma» per sempre — mentre
8
+ // la garanzia di quel segno e' che il supervisore DICHIARA l'uscita, e un
9
+ // supervisore ucciso non l'ha dichiarata.
10
+ //
11
+ // La verita' e' il pane che esegue `cell-exec`: la runtime lo marca al lancio
12
+ // con un'opzione di pane (`@nc_supervisor`), e il server lo cerca qui.
13
+ //
14
+ // UNA SOLA CHIAMATA PER GIRO: `list-panes -a -F <PANES_FMT>`, poi tutto si
15
+ // calcola in memoria. Nessuna chiamata tmux per singola sessione.
16
+ const NC_SUPERVISOR_OPT = '@nc_supervisor';
17
+ const PANES_FMT = `#{session_name}\t#{pane_id}\t#{pane_dead}\t#{${NC_SUPERVISOR_OPT}}`;
18
+
19
+ function parsePanes(raw) {
20
+ return String(raw)
21
+ .split('\n')
22
+ .filter((line) => line.trim() !== '')
23
+ .map((line) => {
24
+ const campi = line.split('\t');
25
+ return {
26
+ session: campi[0] || '',
27
+ paneId: campi[1] || '',
28
+ dead: campi[2] === '1',
29
+ marked: campi[3] === '1',
30
+ };
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Lo stato del supervisore di ogni sessione, dalle sole righe di list-panes:
36
+ * 'vivo' — il pane marcato c'e' ed e' vivo;
37
+ * 'morto' — il pane marcato c'e' ed e' morto (SIGKILL/OOM del supervisore,
38
+ * e la sessione e' sopravvissuta per `remain-on-exit`);
39
+ * 'assente' — nessun pane marcato: la cella e' stata lanciata PRIMA che il
40
+ * marcatore esistesse, oppure la marcatura non e' riuscita.
41
+ */
42
+ function statiSupervisore(raw) {
43
+ const perSessione = new Map();
44
+ for (const pane of parsePanes(raw)) {
45
+ if (!pane.session) continue;
46
+ const stato = perSessione.get(pane.session) || { marcato: null };
47
+ // Un solo pane marcato per lancio: se ne comparissero due, l'ultimo vince.
48
+ if (pane.marked) stato.marcato = pane;
49
+ perSessione.set(pane.session, stato);
50
+ }
51
+ const out = new Map();
52
+ for (const [nome, stato] of perSessione) {
53
+ if (!stato.marcato) out.set(nome, 'assente');
54
+ else out.set(nome, stato.marcato.dead ? 'morto' : 'vivo');
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * La decisione: di questa cella non si puo' leggere lo stato?
61
+ *
62
+ * 'morto' → si': il supervisore non c'e' piu' e nessuno dichiarera' l'uscita.
63
+ *
64
+ * 'assente' → dipende dal FILE DI GENERAZIONE, e la differenza e' la ragione
65
+ * della regola: un lancio NUOVO in cui la marcatura del pane e' fallita ha il
66
+ * segno `exit:1`, quindi il suo `Stop` non scadrebbe mai e nessuno garantisce
67
+ * il supervisore → non si legge. Un lancio VECCHIO (file di una riga, senza
68
+ * segno) non ha nessuna non-scadenza da proteggere: vale il lettore, che fa
69
+ * scadere `Stop` e gli eventi di lavoro a cinque minuti. Il caso peggiore e'
70
+ * limitato a quei cinque minuti, invece di diventare «non verificato» per ogni
71
+ * cella viva su ogni nodo appena aggiornato.
72
+ *
73
+ * 'vivo' → no.
74
+ */
75
+ function senzaSupervisore(stato, uscitaGarantita) {
76
+ if (stato === 'morto') return true;
77
+ if (stato === 'assente') return uscitaGarantita === true;
78
+ return false;
79
+ }
80
+
81
+ module.exports = { NC_SUPERVISOR_OPT, PANES_FMT, parsePanes, statiSupervisore, senzaSupervisore };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.40",
3
+ "version": "0.9.42",
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"