@mmmbuto/nexuscrew 0.8.52 → 0.8.54
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/CHANGELOG.md +140 -0
- package/frontend/dist/assets/{index-Cxwpa2Y_.js → index-E5p49uPq.js} +29 -29
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +256 -3
- package/lib/mcp/server.js +35 -1
- package/lib/mcp/tools.js +1 -1
- package/lib/proxy/federation.js +29 -2
- package/lib/tmux/actions.js +33 -1
- package/lib/update/runner.js +27 -0
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +19 -1
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-E5p49uPq.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-CYi_lhCg.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.54"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -7,6 +7,7 @@ const { execFileSync, spawn } = require('node:child_process');
|
|
|
7
7
|
const fs = require('node:fs');
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
const net = require('node:net');
|
|
10
|
+
const crypto = require('node:crypto');
|
|
10
11
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
11
12
|
const { installPath: serviceInstallPath, ensureLinuxTmuxSurvival } = require('./service.js');
|
|
12
13
|
const { fleetInstallPath } = require('./fleet-service.js');
|
|
@@ -37,7 +38,8 @@ Usage:
|
|
|
37
38
|
nexuscrew boot enable startup at boot (use: boot off|status)
|
|
38
39
|
nexuscrew status show service, port, roles and node status
|
|
39
40
|
nexuscrew stop stop the background service
|
|
40
|
-
nexuscrew restart restart the background service
|
|
41
|
+
nexuscrew restart restart the background service (verifies it came back)
|
|
42
|
+
nexuscrew autoupdate turn automatic updates on or off (use: autoupdate on|off|status)
|
|
41
43
|
nexuscrew doctor run local diagnostics
|
|
42
44
|
nexuscrew nodes list and manage connected peers (use: nodes help)
|
|
43
45
|
nexuscrew help show this help
|
|
@@ -544,6 +546,25 @@ async function probeNexusCrew(port, token, opts = {}) {
|
|
|
544
546
|
} catch (_) { return false; }
|
|
545
547
|
}
|
|
546
548
|
|
|
549
|
+
// probeNexusCrew restituisce un BOOLEANO e collassa ogni non-200 su `false`:
|
|
550
|
+
// per i suoi molti chiamanti va bene, e cambiarne il contratto ripercuoterebbe
|
|
551
|
+
// ovunque. Ma «non risponde» e «risponde 401» sono due cose opposte — un 401
|
|
552
|
+
// dice che il servizio E' IN PIEDI e che il token locale non vale — e chi deve
|
|
553
|
+
// spiegare un riavvio ha bisogno di distinguerle, altrimenti dichiara fallito
|
|
554
|
+
// un riavvio riuscito e manda a cercare un processo morto che e' vivo.
|
|
555
|
+
// Rilievo dell'audit: il caso token-assente era gia' coperto, questo no.
|
|
556
|
+
async function probeNexusCrewStatus(port, token, opts = {}) {
|
|
557
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
558
|
+
if (typeof fetchImpl !== 'function') return null;
|
|
559
|
+
try {
|
|
560
|
+
const r = await fetchImpl(`http://127.0.0.1:${port}/api/config`, {
|
|
561
|
+
headers: { authorization: `Bearer ${token || ''}` },
|
|
562
|
+
signal: AbortSignal.timeout?.(700),
|
|
563
|
+
});
|
|
564
|
+
return r.status;
|
|
565
|
+
} catch (_) { return null; } // nessuna risposta: e' un altro guasto
|
|
566
|
+
}
|
|
567
|
+
|
|
547
568
|
async function waitForNexusCrew(port, token, opts = {}) {
|
|
548
569
|
const probe = opts.probeImpl || probeNexusCrew;
|
|
549
570
|
const attempts = opts.waitAttempts === undefined ? 30 : opts.waitAttempts;
|
|
@@ -811,6 +832,113 @@ function logs(opts = {}) {
|
|
|
811
832
|
return { platform, bin, args, follow, keepAlive: true };
|
|
812
833
|
}
|
|
813
834
|
|
|
835
|
+
// autoupdate on|off|status — l'aggiornamento automatico si spegne anche da qui.
|
|
836
|
+
//
|
|
837
|
+
// PERCHE' NEL CLI, visto che la casella esiste gia' in Settings. Perche' il
|
|
838
|
+
// momento in cui serve spegnerlo e' quello in cui la PWA non si apre: il nodo
|
|
839
|
+
// si e' aggiornato, il servizio non e' tornato su, e la riga di comando e'
|
|
840
|
+
// l'unica superficie rimasta. Un controllo che vive solo dove il guasto lo
|
|
841
|
+
// rende irraggiungibile e' mezzo assente.
|
|
842
|
+
//
|
|
843
|
+
// A SERVIZIO ACCESO PASSA DALL'API, e non e' un dettaglio: scrivere il file
|
|
844
|
+
// mentre il processo vive lascerebbe il manager in memoria con il vecchio
|
|
845
|
+
// valore, e continuerebbe ad aggiornare all'ora prevista. Un interruttore che
|
|
846
|
+
// risulta spento e non spegne e' peggio di un interruttore che manca. La route
|
|
847
|
+
// invece persiste E chiama `setEnabled`, quindi vale subito.
|
|
848
|
+
function autoUpdateCommand(args, opts = {}) {
|
|
849
|
+
const log = opts.log || console.log;
|
|
850
|
+
const azione = String(args[0] || 'status').toLowerCase();
|
|
851
|
+
if (!['on', 'off', 'status'].includes(azione)) {
|
|
852
|
+
log('uso: nexuscrew autoupdate on|off|status');
|
|
853
|
+
return { code: 1 };
|
|
854
|
+
}
|
|
855
|
+
const { configPath, tokenPath } = urlmod.resolvePaths(opts);
|
|
856
|
+
const port = urlmod.loadPort(opts);
|
|
857
|
+
const token = urlmod.readToken(tokenPath);
|
|
858
|
+
const attivo = (opts.isServiceRunningImpl || isServiceRunning)({ ...opts });
|
|
859
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
860
|
+
|
|
861
|
+
const daFile = () => {
|
|
862
|
+
try {
|
|
863
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
864
|
+
return cfg.autoUpdate !== false;
|
|
865
|
+
} catch (_) { return true; } // default ON, come la configurazione
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
if (azione === 'status') {
|
|
869
|
+
// A servizio acceso vince cio' che il processo sta DAVVERO facendo, non
|
|
870
|
+
// cio' che il file dice: se i due divergono, e' il primo a spegnere o
|
|
871
|
+
// accendere gli aggiornamenti.
|
|
872
|
+
if (!attivo) {
|
|
873
|
+
log(`autoupdate: ${daFile() ? 'on' : 'off'} (da configurazione; servizio non attivo)`);
|
|
874
|
+
return { code: 0 };
|
|
875
|
+
}
|
|
876
|
+
return fetchImpl(`http://127.0.0.1:${port}/api/settings`, {
|
|
877
|
+
headers: { authorization: `Bearer ${token}` },
|
|
878
|
+
}).then(async (r) => {
|
|
879
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
880
|
+
const j = await r.json();
|
|
881
|
+
const acceso = j.autoUpdate !== false;
|
|
882
|
+
log(`autoupdate: ${acceso ? 'on' : 'off'}`);
|
|
883
|
+
if (j.update && j.update.latest) log(` ultima versione vista su npm latest: ${j.update.latest}`);
|
|
884
|
+
return { code: 0 };
|
|
885
|
+
}).catch((e) => {
|
|
886
|
+
log(`autoupdate: ${daFile() ? 'on' : 'off'} (da configurazione; servizio non interrogabile: ${e.message})`);
|
|
887
|
+
return { code: 0 };
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const acceso = azione === 'on';
|
|
892
|
+
if (!attivo) {
|
|
893
|
+
// Servizio spento: si scrive il file, e si DICE che vale al prossimo avvio.
|
|
894
|
+
// Tacerlo lascerebbe credere che abbia gia' effetto.
|
|
895
|
+
let cfg = {};
|
|
896
|
+
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (_) { /* file nuovo */ }
|
|
897
|
+
// Scrittura atomica e rifiuto dei symlink, come fa la route che scrive lo
|
|
898
|
+
// stesso file: tmp nella stessa directory, 0600, rename. Un `writeFileSync`
|
|
899
|
+
// diretto puo' lasciare un config.json troncato se il processo muore a
|
|
900
|
+
// meta' — e un config.json illeggibile e' un nodo che non riparte, cioe'
|
|
901
|
+
// proprio il guasto che questo comando serve a evitare. Rilievo dell'audit.
|
|
902
|
+
try {
|
|
903
|
+
if (fs.lstatSync(configPath).isSymbolicLink()) {
|
|
904
|
+
log('autoupdate: config.json e\' un symlink, non lo scrivo');
|
|
905
|
+
return { code: 1 };
|
|
906
|
+
}
|
|
907
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
908
|
+
const dir = path.dirname(configPath);
|
|
909
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
910
|
+
const tmp = path.join(dir, `.${path.basename(configPath)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
911
|
+
try {
|
|
912
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ ...cfg, autoUpdate: acceso }, null, 2)}\n`, { mode: 0o600 });
|
|
913
|
+
fs.chmodSync(tmp, 0o600);
|
|
914
|
+
fs.renameSync(tmp, configPath);
|
|
915
|
+
} catch (e) {
|
|
916
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
|
|
917
|
+
throw e;
|
|
918
|
+
}
|
|
919
|
+
log(`autoupdate: ${azione} (scritto in configurazione; vale al prossimo avvio del servizio)`);
|
|
920
|
+
return { code: 0 };
|
|
921
|
+
}
|
|
922
|
+
return fetchImpl(`http://127.0.0.1:${port}/api/settings/config`, {
|
|
923
|
+
method: 'POST',
|
|
924
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
925
|
+
body: JSON.stringify({ autoUpdate: acceso }),
|
|
926
|
+
}).then(async (r) => {
|
|
927
|
+
if (!r.ok) {
|
|
928
|
+
const j = await r.json().catch(() => ({}));
|
|
929
|
+
log(`autoupdate: non applicato — ${j.error || `HTTP ${r.status}`}`);
|
|
930
|
+
return { code: 1 };
|
|
931
|
+
}
|
|
932
|
+
log(`autoupdate: ${azione} (applicato subito e salvato)`);
|
|
933
|
+
return { code: 0 };
|
|
934
|
+
}).catch((e) => {
|
|
935
|
+
log(`autoupdate: non applicato — ${e.message}`);
|
|
936
|
+
log(' il servizio risulta attivo ma non risponde: non scrivo il file, perche\' resterebbe');
|
|
937
|
+
log(' un valore che il processo vivo non conosce.');
|
|
938
|
+
return { code: 1 };
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
814
942
|
// update: npm i -g @latest + restart se attivo. Fallimento npm -> messaggio chiaro, code 1.
|
|
815
943
|
function update(opts = {}) {
|
|
816
944
|
const execImpl = opts.execImpl || execFileSync;
|
|
@@ -1158,9 +1286,134 @@ function dispatch(argv, opts = {}) {
|
|
|
1158
1286
|
const result = stop({ ...opts, log });
|
|
1159
1287
|
return { code: result.stopped || ['not running', 'stale pidfile'].includes(result.reason) ? 0 : 1 };
|
|
1160
1288
|
}
|
|
1289
|
+
if (cmd === 'autoupdate') {
|
|
1290
|
+
return autoUpdateCommand(rest.slice(1), { ...opts, log });
|
|
1291
|
+
}
|
|
1161
1292
|
if (cmd === 'restart') {
|
|
1162
|
-
|
|
1163
|
-
|
|
1293
|
+
// Seam come negli altri punti del file (`opts.restartImpl || restart`):
|
|
1294
|
+
// serve a poter dichiarare in prova QUALE runtime e' in piedi, perche' il
|
|
1295
|
+
// ritentativo si comporta in modo diverso fra gestito e portatile — e senza
|
|
1296
|
+
// il seam un test finisce per esercitare il ramo che non intendeva.
|
|
1297
|
+
const result = (opts.restartImpl || restart)({ ...opts, log });
|
|
1298
|
+
if (!result.restarted) return { code: 1 };
|
|
1299
|
+
// IL RIAVVIO ORA VERIFICA DI ESSERE TORNATO SU, e prima no. `restart`
|
|
1300
|
+
// dichiarava successo appena `systemctl restart` (o launchctl, o l'avvio
|
|
1301
|
+
// portatile) ritornava: quella e' la conferma che il COMANDO e' partito,
|
|
1302
|
+
// non che il servizio risponda. Su Termux, il 2026-08-07, ha restituito 0
|
|
1303
|
+
// con il servizio morto, e il nodo e' rimasto giu' oltre quattro ore senza
|
|
1304
|
+
// che nessuno lo sapesse — da fuori si vedeva solo un KO generico.
|
|
1305
|
+
//
|
|
1306
|
+
// La verifica esisteva gia' e veniva usata dal percorso di auto-update e
|
|
1307
|
+
// dal bootstrap Fleet («serve un restart verificato»); mancava proprio sul
|
|
1308
|
+
// comando che una persona digita a mano. Con l'auto-update acceso questo
|
|
1309
|
+
// riavvio avviene DA SOLO su ogni nodo, quindi un esito non verificato si
|
|
1310
|
+
// moltiplica per la flotta.
|
|
1311
|
+
//
|
|
1312
|
+
// Il ramo restituisce una PROMESSA e `dispatch` resta sincrona: e' il
|
|
1313
|
+
// disegno che il file ha gia' (dispatchNodes fa lo stesso) e il chiamante
|
|
1314
|
+
// avvolge in `Promise.resolve`. Cambiare la firma di dispatch avrebbe
|
|
1315
|
+
// rotto ottanta test che la chiamano in modo sincrono, per una verifica
|
|
1316
|
+
// che riguarda un comando solo.
|
|
1317
|
+
const { tokenPath } = urlmod.resolvePaths(opts);
|
|
1318
|
+
const port = urlmod.loadPort(opts);
|
|
1319
|
+
const token = urlmod.readToken(tokenPath);
|
|
1320
|
+
// SENZA TOKEN NON SI PUO' DIRE NIENTE SULLA SALUTE, e soprattutto non si
|
|
1321
|
+
// deve dire qualcosa di sbagliato: la sonda fallirebbe per autenticazione e
|
|
1322
|
+
// il messaggio incolperebbe la porta o il processo, mandando a cercare dove
|
|
1323
|
+
// il problema non e'. Rilievo dell'audit. Il riavvio e' comunque partito,
|
|
1324
|
+
// quindi non e' un fallimento: e' una verifica che non si e' potuta fare, e
|
|
1325
|
+
// si dice cosi'.
|
|
1326
|
+
if (!token) {
|
|
1327
|
+
log('restart: comando eseguito, ma la salute NON e\' verificabile senza token locale.');
|
|
1328
|
+
log(' Controlla a mano che il servizio risponda.');
|
|
1329
|
+
return { code: 0 };
|
|
1330
|
+
}
|
|
1331
|
+
const attesa = (sano) => {
|
|
1332
|
+
if (sano) {
|
|
1333
|
+
log(`restart: servizio verificato su 127.0.0.1:${port}`);
|
|
1334
|
+
return { code: 0 };
|
|
1335
|
+
}
|
|
1336
|
+
// NON E' ANCORA UN FALLIMENTO: si prova a capire PERCHE' e, se e' la
|
|
1337
|
+
// causa nota, si rimedia una volta sola.
|
|
1338
|
+
//
|
|
1339
|
+
// LA CAUSA NOTA. Sul percorso portatile — quello di Termux, dove non c'e'
|
|
1340
|
+
// un gestore di servizi che rialzi il processo — `restart` avvia il
|
|
1341
|
+
// nuovo processo SUBITO dopo aver fermato il vecchio, senza aspettare che
|
|
1342
|
+
// muoia ne' che la porta si liberi. Il nuovo non riesce ad ascoltare,
|
|
1343
|
+
// esce, e nessuno se ne accorge: servizio giu', tunnel su (e' un
|
|
1344
|
+
// processo SSH separato), nessun auto-recupero. Misurato il 2026-08-07:
|
|
1345
|
+
// oltre venti minuti in quello stato.
|
|
1346
|
+
//
|
|
1347
|
+
// Il percorso dell'AUTO-UPDATE fa gia' la cosa giusta — aspetta che il
|
|
1348
|
+
// processo sia morto E che la porta sia libera, fino a sei secondi, e
|
|
1349
|
+
// solo allora avvia. Le due strade erano divergenti, e quella che una
|
|
1350
|
+
// persona digita a mano era la meno prudente.
|
|
1351
|
+
//
|
|
1352
|
+
// PRIMA DI DARE LA COLPA A QUALCUNO, si guarda se il servizio risponde
|
|
1353
|
+
// affatto. Un 401 significa che E' IN PIEDI e che il token locale non
|
|
1354
|
+
// vale: il riavvio e' riuscito, e dichiararlo fallito manderebbe a
|
|
1355
|
+
// cercare un processo morto che invece e' vivo.
|
|
1356
|
+
return (opts.probeStatusImpl || probeNexusCrewStatus)(port, token, opts).then((stato) => {
|
|
1357
|
+
if (stato === 401 || stato === 403) {
|
|
1358
|
+
log(`restart: il servizio RISPONDE su 127.0.0.1:${port}, ma il token locale non e' valido.`);
|
|
1359
|
+
log(' Il riavvio e\' riuscito; e\' la credenziale a non funzionare.');
|
|
1360
|
+
log(' Rigenera il token locale, poi riprova a collegarti.');
|
|
1361
|
+
return { code: 1 };
|
|
1362
|
+
}
|
|
1363
|
+
return continua();
|
|
1364
|
+
});
|
|
1365
|
+
};
|
|
1366
|
+
const continua = () => {
|
|
1367
|
+
// SOLO SUL RUNTIME PORTATILE. E' li' che manca un supervisore — ed e'
|
|
1368
|
+
// esattamente la ragione per cui il difetto esiste: su Termux nessuno
|
|
1369
|
+
// rialza il processo. Dove il servizio e' gestito (systemd, launchd) il
|
|
1370
|
+
// supervisore c'e' ed e' suo il compito: avviare noi un processo
|
|
1371
|
+
// portatile accanto significherebbe metterne in piedi uno che il gestore
|
|
1372
|
+
// non conosce, mentre il gestore puo' rialzare la propria unita' — due
|
|
1373
|
+
// processi sulla stessa porta, e il nostro sopravvivrebbe allo stop del
|
|
1374
|
+
// servizio. Li' si riferisce e basta.
|
|
1375
|
+
//
|
|
1376
|
+
// Difetto mio, trovato rileggendo prima dell'audit: la prima stesura
|
|
1377
|
+
// chiamava `startPortable` in ogni caso.
|
|
1378
|
+
if (result.runtimeOwner === 'managed') {
|
|
1379
|
+
log(`restart: il servizio NON risponde su 127.0.0.1:${port} dopo il riavvio.`);
|
|
1380
|
+
log(' Il runtime e\' gestito dal servizio di sistema: non avvio un processo accanto.');
|
|
1381
|
+
log(' Controlla lo stato e i log dell\'unita\' di sistema.');
|
|
1382
|
+
return { code: 1 };
|
|
1383
|
+
}
|
|
1384
|
+
// UN SOLO RITENTATIVO, e solo a porta libera. Se la porta e' ancora
|
|
1385
|
+
// occupata il problema e' un altro (un processo che non muore) e
|
|
1386
|
+
// riprovare lo nasconderebbe; se e' libera e il servizio non c'e', il
|
|
1387
|
+
// nuovo processo e' uscito e riavviarlo e' esattamente il rimedio.
|
|
1388
|
+
// Ripetere all'infinito trasformerebbe un guasto in un ciclo.
|
|
1389
|
+
return (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1').then((libera) => {
|
|
1390
|
+
if (!libera) {
|
|
1391
|
+
log(`restart: il servizio NON risponde su 127.0.0.1:${port} e la porta e' ancora occupata.`);
|
|
1392
|
+
log(' Qualcosa tiene la porta senza servire: non riavvio a vuoto.');
|
|
1393
|
+
log(' Controlla i log del servizio e i processi rimasti.');
|
|
1394
|
+
return { code: 1 };
|
|
1395
|
+
}
|
|
1396
|
+
log(`restart: nessun servizio su ${port} e porta libera — il processo e' uscito. Riprovo una volta.`);
|
|
1397
|
+
(opts.startPortableImpl || startPortable)({ ...opts, spawnImpl: opts.spawnImpl });
|
|
1398
|
+
return (opts.waitForRuntimeImpl || waitForNexusCrew)(port, token, {
|
|
1399
|
+
...opts, waitAttempts: opts.waitAttempts === undefined ? 60 : opts.waitAttempts,
|
|
1400
|
+
waitDelayMs: opts.waitDelayMs === undefined ? 250 : opts.waitDelayMs,
|
|
1401
|
+
}).then((sanoOra) => {
|
|
1402
|
+
if (sanoOra) {
|
|
1403
|
+
log(`restart: servizio verificato su 127.0.0.1:${port} al secondo tentativo`);
|
|
1404
|
+
return { code: 0 };
|
|
1405
|
+
}
|
|
1406
|
+
log(`restart: il servizio NON risponde su 127.0.0.1:${port} nemmeno dopo un secondo avvio.`);
|
|
1407
|
+
log(' Il comando di riavvio e\' partito, il processo non resta su.');
|
|
1408
|
+
log(' Controlla i log del servizio; su Termux puo\' servire riavviare il dispositivo.');
|
|
1409
|
+
return { code: 1 };
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
};
|
|
1413
|
+
return (opts.waitForRuntimeImpl || waitForNexusCrew)(port, token, {
|
|
1414
|
+
...opts, waitAttempts: opts.waitAttempts === undefined ? 60 : opts.waitAttempts,
|
|
1415
|
+
waitDelayMs: opts.waitDelayMs === undefined ? 250 : opts.waitDelayMs,
|
|
1416
|
+
}).then(attesa);
|
|
1164
1417
|
}
|
|
1165
1418
|
// Internal runtime commands used by service managers and MCP clients. They
|
|
1166
1419
|
// are intentionally omitted from HELP and are not configuration surfaces.
|
package/lib/mcp/server.js
CHANGED
|
@@ -215,10 +215,44 @@ function createMcpServer(opts = {}) {
|
|
|
215
215
|
});
|
|
216
216
|
} catch (e) { throw transportError(baseUrl, e); }
|
|
217
217
|
const j = await r.json().catch(() => ({}));
|
|
218
|
-
if (!r.ok)
|
|
218
|
+
if (!r.ok) {
|
|
219
|
+
const base = j.error ? `API ${r.status}: ${j.error}` : `API ${r.status}`;
|
|
220
|
+
throw new Error(base + await disallineamentoDiVersione());
|
|
221
|
+
}
|
|
219
222
|
return j;
|
|
220
223
|
}
|
|
221
224
|
|
|
225
|
+
// NC-R. Aggiornare NexusCrew NON aggiorna il bridge MCP delle celle gia' in
|
|
226
|
+
// piedi: quel processo e' partito col codice di prima e ci resta fino al
|
|
227
|
+
// riavvio della cella. Il sintomo e' crudele — si installa una correzione, si
|
|
228
|
+
// riprova, e si riceve l'errore VECCHIO — e chi lo subisce conclude che la
|
|
229
|
+
// correzione non funziona. E' successo il 2026-08-07 su rc.26, a me, e ci ho
|
|
230
|
+
// messo un giro intero a capirlo.
|
|
231
|
+
//
|
|
232
|
+
// Il momento in cui serve saperlo e' esattamente quello in cui qualcosa
|
|
233
|
+
// fallisce, quindi la verifica sta SOLO sul ramo d'errore: a regime non costa
|
|
234
|
+
// niente, e non si puo' nemmeno mettere in cache all'avvio — la versione che
|
|
235
|
+
// cambia e' quella dell'hub, e cambia proprio mentre questo processo vive.
|
|
236
|
+
//
|
|
237
|
+
// Non trasforma mai un errore in un altro: se la verifica fallisce, l'errore
|
|
238
|
+
// originale esce come sarebbe uscito comunque.
|
|
239
|
+
async function disallineamentoDiVersione() {
|
|
240
|
+
try {
|
|
241
|
+
const r = await fetchImpl(`${baseUrl}/api/config`, {
|
|
242
|
+
headers: { authorization: `Bearer ${readToken()}` },
|
|
243
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
244
|
+
});
|
|
245
|
+
if (!r.ok) return '';
|
|
246
|
+
const cfg = await r.json();
|
|
247
|
+
if (typeof cfg.version !== 'string' || cfg.version === VERSION) return '';
|
|
248
|
+
return `\n\nNOTA: questo bridge MCP e' la versione ${VERSION}, l'hub e' la ${cfg.version}.`
|
|
249
|
+
+ ' Aggiornare NexusCrew non aggiorna il bridge di una cella gia\' avviata:'
|
|
250
|
+
+ ' riavvia questa cella se ti aspettavi un comportamento diverso.';
|
|
251
|
+
} catch (_) {
|
|
252
|
+
return '';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
222
256
|
const ctx = {
|
|
223
257
|
session,
|
|
224
258
|
identity,
|
package/lib/mcp/tools.js
CHANGED
|
@@ -321,7 +321,7 @@ const TOOLS = [
|
|
|
321
321
|
// Restituirlo cosi' evita di far leggere un successo dove c'e' un rifiuto.
|
|
322
322
|
// Nessun conteggio: il dispatcher non lo propaga, e un ramo che non puo'
|
|
323
323
|
// mai essere vero e' codice morto travestito da informazione
|
|
324
|
-
// (rilievo R1 di
|
|
324
|
+
// (rilievo R1 di un audit indipendente su rc.14). Se un giorno servira' il dettaglio,
|
|
325
325
|
// va fatto propagare da forward(), non dedotto qui.
|
|
326
326
|
if (target) return { status: j.status, ...(j.reason ? { reason: j.reason } : {}) };
|
|
327
327
|
return { delivered: j.delivered };
|
package/lib/proxy/federation.js
CHANGED
|
@@ -351,8 +351,35 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
|
|
|
351
351
|
} catch (e) {
|
|
352
352
|
out.transport = 'down';
|
|
353
353
|
out.status = 'down';
|
|
354
|
-
|
|
355
|
-
|
|
354
|
+
// DUE GUASTI DIVERSI, e prima avevano lo stesso messaggio. Con un canale
|
|
355
|
+
// inverso SSH il listener sull'hub e' sshd: se il dispositivo non e'
|
|
356
|
+
// connesso non c'e' nessun listener e la connessione viene RIFIUTATA; se
|
|
357
|
+
// invece il tunnel regge ma NexusCrew sul dispositivo e' morto, sshd
|
|
358
|
+
// accetta, inoltra, e la connessione viene AZZERATA dall'altro capo.
|
|
359
|
+
//
|
|
360
|
+
// MISURATO il 2026-08-07 sui tre casi reali contemporaneamente presenti:
|
|
361
|
+
// tunnel su + servizio giu' -> ECONNRESET
|
|
362
|
+
// nessun listener -> ECONNREFUSED
|
|
363
|
+
// tutto su -> HTTP (401)
|
|
364
|
+
//
|
|
365
|
+
// Perche' vale la pena distinguerli: «peer non raggiungibile» ha mandato
|
|
366
|
+
// l'indagine nella federazione e nel pairing per quattro ore, mentre il
|
|
367
|
+
// difetto era un servizio che non era ripartito sul dispositivo. Sono due
|
|
368
|
+
// guasti con due rimedi diversi — uno si risolve sulla rete, l'altro
|
|
369
|
+
// andando sul dispositivo — e un messaggio unico li fa cercare nel posto
|
|
370
|
+
// sbagliato la meta' delle volte.
|
|
371
|
+
const codice = (e && (e.code || (e.cause && e.cause.code))) || '';
|
|
372
|
+
if (e && (e.name === 'AbortError' || codice === 'ETIMEDOUT')) {
|
|
373
|
+
out.detail = `peer non raggiungibile (timeout ${timeoutMs}ms)`;
|
|
374
|
+
} else if (codice === 'ECONNREFUSED') {
|
|
375
|
+
out.detail = `canale inverso non attivo sulla porta ${port}: il dispositivo non e' connesso`;
|
|
376
|
+
out.layer = 'tunnel';
|
|
377
|
+
} else if (codice === 'ECONNRESET' || codice === 'ECONNABORTED' || codice === 'EPIPE') {
|
|
378
|
+
out.detail = `canale inverso attivo sulla porta ${port}, ma NexusCrew non risponde sul dispositivo`;
|
|
379
|
+
out.layer = 'service';
|
|
380
|
+
} else {
|
|
381
|
+
out.detail = 'peer non raggiungibile (tcp refused/down)';
|
|
382
|
+
}
|
|
356
383
|
return out;
|
|
357
384
|
} finally {
|
|
358
385
|
if (timer) clearTimeout(timer);
|
package/lib/tmux/actions.js
CHANGED
|
@@ -157,7 +157,39 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
|
|
|
157
157
|
// inside the composer. C-e is sent as its own tmux command after the paste,
|
|
158
158
|
// then the pane is revalidated before the separate Enter. Other clients
|
|
159
159
|
// still receive a short separation between paste and submit.
|
|
160
|
-
|
|
160
|
+
//
|
|
161
|
+
// NC-Q: L'ATTESA SCALA COL PAYLOAD, e prima era una costante. Il tempo che
|
|
162
|
+
// un TUI impiega a ingerire un bracketed paste cresce con la lunghezza —
|
|
163
|
+
// Claude Code sopra una certa taglia lo COLLASSA («paste again to expand»)
|
|
164
|
+
// — mentre l'attesa era fissa. Oltre quella soglia l'Enter arriva mentre il
|
|
165
|
+
// client sta ancora digerendo e viene mangiato: il testo resta nel
|
|
166
|
+
// composer, la cella non si muove, e il mittente riceve `submitted`. E'
|
|
167
|
+
// una perdita silenziosa, ed e' la peggiore: chi manda crede di aver
|
|
168
|
+
// consegnato.
|
|
169
|
+
//
|
|
170
|
+
// MISURATO il 2026-08-07, stesso bersaglio a 11 minuti di distanza: ~2900
|
|
171
|
+
// caratteri -> mai elaborato per NOVE ORE; ~60 caratteri -> in lavorazione
|
|
172
|
+
// dopo dodici secondi. Lo stesso era successo con `claude.zai-a` a 150 ms e
|
|
173
|
+
// con `codex-vl` a 400: la costante piu' alta non bastava, perche' il
|
|
174
|
+
// problema non e' quale costante ma che sia una costante.
|
|
175
|
+
//
|
|
176
|
+
// I numeri sono un'EURISTICA, non una misura del tempo di ingestione: 150
|
|
177
|
+
// ms ogni 500 caratteri oltre i primi 500, col vecchio valore come minimo.
|
|
178
|
+
// Il gradino usa `floor` di proposito: sotto i 500 caratteri non cambia
|
|
179
|
+
// NIENTE, perche' quei messaggi erano gia' affidabili e rallentarli sarebbe
|
|
180
|
+
// stato un costo pagato da tutti per un difetto che non li riguarda.
|
|
181
|
+
//
|
|
182
|
+
// Nessun tetto esplicito: il testo e' gia' limitato a MAX_SUBMIT, quindi
|
|
183
|
+
// l'attesa non puo' superare i ~2,5 s per costruzione. Un `Math.min` qui
|
|
184
|
+
// sarebbe un ramo che non si esegue mai — e un ramo irraggiungibile e' un
|
|
185
|
+
// pezzo di codice che promette di proteggere da qualcosa che non puo'
|
|
186
|
+
// accadere.
|
|
187
|
+
//
|
|
188
|
+
// Riduce di molto la finestra; non la chiude. `submitted` continua a
|
|
189
|
+
// significare paste+Enter, non accettazione — e resta vero che dopo un
|
|
190
|
+
// messaggio lungo conviene verificare che la cella si sia mossa.
|
|
191
|
+
const attesaBase = codexComposer ? 400 : 150;
|
|
192
|
+
await delay(attesaBase + Math.floor(text.length / 500) * 150);
|
|
161
193
|
if (!(await paneAlive())) return { submitted: false, reason: 'sessione terminata durante la consegna' };
|
|
162
194
|
if (codexComposer) {
|
|
163
195
|
const flush = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'C-e']);
|
package/lib/update/runner.js
CHANGED
|
@@ -36,6 +36,33 @@ async function restartRuntime(opts = {}) {
|
|
|
36
36
|
}
|
|
37
37
|
mode = 'service';
|
|
38
38
|
} else {
|
|
39
|
+
// I TUNNEL SI FERMANO PRIMA, come fa il riavvio manuale — e prima qui non
|
|
40
|
+
// accadeva. `commands.restart` chiama `stopManagedTunnels` sul ramo
|
|
41
|
+
// gestito (commands.js), quindi il percorso di sopra e' coperto; questo
|
|
42
|
+
// ramo portatile — quello di un dispositivo senza gestore di servizi —
|
|
43
|
+
// uccideva il servizio e lasciava vivi i supervisori dei tunnel.
|
|
44
|
+
//
|
|
45
|
+
// COSA PRODUCE, osservato sul campo il 2026-08-07: al riavvio il servizio
|
|
46
|
+
// nuovo trova un supervisore ancora vivo che non riesce ad attribuirsi
|
|
47
|
+
// — gli servono runId, uid, start-time e un file di stato concorde — e
|
|
48
|
+
// allora NON lo ferma e NON ne avvia uno suo (`lib/nodes/tunnel.js`:
|
|
49
|
+
// «unattributable existing supervisor», «orphan tunnel cleanup failed»).
|
|
50
|
+
// Il canale inverso resta appeso a un orfano che nessuno riconcilia: il
|
|
51
|
+
// peer risulta giu' finche' qualcuno non interviene a mano.
|
|
52
|
+
//
|
|
53
|
+
// La correlazione che lo ha isolato, misurata su un dispositivo reale:
|
|
54
|
+
// riavvii via `nexuscrew restart` -> peer su, 2 su 2; riavvii per
|
|
55
|
+
// aggiornamento automatico -> peer giu', 2 su 2. La differenza fra i due
|
|
56
|
+
// percorsi era esattamente questa riga.
|
|
57
|
+
//
|
|
58
|
+
// Best-effort: un errore nel fermare i tunnel non deve impedire
|
|
59
|
+
// l'aggiornamento, che e' il compito principale. Ma tacerlo lo
|
|
60
|
+
// renderebbe irrintracciabile la prossima volta.
|
|
61
|
+
try {
|
|
62
|
+
(opts.stopTunnelsImpl || commands.stopManagedTunnels)({ home, platform });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (opts.log) opts.log(`restart portatile: stop tunnel non riuscito — ${e && e.message ? e.message : e}`);
|
|
65
|
+
}
|
|
39
66
|
const pidPath = pidf.defaultPidfilePath(home);
|
|
40
67
|
const meta = pidf.readPidfile(pidPath);
|
|
41
68
|
if (meta && pidf.isAlive(meta)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nexuscrew-agent
|
|
3
|
-
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime or deck context, manage exact authorized VL micro-device nodes, list authorized Fleet cells, message an exact active cell, read its inbox, deliver a file, recover from local tmux messages that remain unsubmitted or garbled, or when a drag in the web terminal does not scroll a full-screen TUI. Prefer nc_notify, nc_ask, nc_status, nc_deck, nc_cells, nc_vl_nodes, nc_vl_command, nc_send_cell, nc_inbox, and nc_send_file; use bundled tmux/file helpers only as a declared compatibility fallback.
|
|
3
|
+
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime, identity or deck context, diagnose a Fleet cell that will not start, manage exact authorized VL micro-device nodes, list authorized Fleet cells, message an exact active cell, speak on a target node or audio group, read its inbox, deliver a file, recover from local tmux messages that remain unsubmitted or garbled, or when a drag in the web terminal does not scroll a full-screen TUI. Prefer nc_notify, nc_ask, nc_status, nc_identity, nc_deck, nc_cells, nc_cell_diagnostics, nc_vl_nodes, nc_vl_command, nc_send_cell, nc_speak, nc_speak_group, nc_inbox, and nc_send_file; use bundled tmux/file helpers only as a declared compatibility fallback.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# NexusCrew Agent I/O
|
|
@@ -25,6 +25,12 @@ When the client exposes the NexusCrew MCP server, use these tools directly:
|
|
|
25
25
|
| Revoke an exact VL pairing | `nc_vl_revoke` |
|
|
26
26
|
| List files received for the current session | `nc_inbox` |
|
|
27
27
|
| Deliver an absolute file path under the user's home | `nc_send_file` |
|
|
28
|
+
| Speak an utterance on an exact target node | `nc_speak` |
|
|
29
|
+
| Speak to a named local audio group | `nc_speak_group` |
|
|
30
|
+
| Check or stop an utterance you started | `nc_speak_status`, `nc_speak_stop` |
|
|
31
|
+
| Check or stop a group utterance you started | `nc_speak_group_status`, `nc_speak_group_stop` |
|
|
32
|
+
| Read who the caller is, without a session or token | `nc_identity` |
|
|
33
|
+
| Read why a local Fleet cell failed to start | `nc_cell_diagnostics` |
|
|
28
34
|
|
|
29
35
|
Apply these rules:
|
|
30
36
|
|
|
@@ -37,6 +43,11 @@ Apply these rules:
|
|
|
37
43
|
- Use `nc_vl_nodes` immediately before VL mutation. Mutation tools require an active local Fleet caller and the full owner-qualified VL ID. `nc_vl_command` `submitted` means live-poll delivery only; require the same ID in `lastAck`. No offline queue exists.
|
|
38
44
|
- Use `nc_inbox` instead of guessing an inbox path when the tool is available.
|
|
39
45
|
- Pass `nc_send_file` an existing absolute regular-file path below the user's home. Let NexusCrew choose and sanitize the outbox name.
|
|
46
|
+
- Speak only when audio is the point. `nc_speak` needs an exact target instance id and returns a caller-scoped receipt with a per-endpoint status (`refused`, `unreachable`, `accepted`) — accepted means the node took the utterance, not that a human heard it. Keep the text short: a spoken line that runs long is worse than no line, and the detail belongs in a file or a notification. A status or stop call only ever reaches an utterance you started; you cannot inspect or silence someone else's.
|
|
47
|
+
- `nc_speak_group` expands a named local group into exact instance ids. `primary-failover` tries one endpoint at a time; `fanout` is explicit. Neither receipt reports text, language or voice back to you — that is deliberate, and it means the receipt cannot be used to read what another caller said.
|
|
48
|
+
- Consent to use a room's speaker is granted locally on the node that owns it and never across the federation. Speaking and stopping travel; granting does not.
|
|
49
|
+
- `nc_identity` answers "who am I to this bridge" with non-sensitive data only, and works without a tmux session or a token. Use it when a tool refuses you and you are not sure which caller the server sees, instead of guessing from the environment.
|
|
50
|
+
- `nc_cell_diagnostics` returns the redacted shell command and the last bounded spawn or start failure for one local Fleet cell. Use it when a cell will not come up, before reading state files.
|
|
40
51
|
- Do not treat an MCP notification as a substitute for the final response required by the active client.
|
|
41
52
|
|
|
42
53
|
The MCP server is the stdio command `nexuscrew mcp` and must be registered in the host AI client. If the `nc_*` tools are not exposed, report that the bridge is not configured in that session and use the fallback flows below where applicable.
|
|
@@ -134,6 +145,10 @@ work. The setting is also applied to windows created later in that session.
|
|
|
134
145
|
| Discover authorized cells across nodes | `nc_cells` |
|
|
135
146
|
| Submit to an exact active Fleet cell | `nc_send_cell` |
|
|
136
147
|
| Discover/manage a paired VL micro-device | `nc_vl_nodes`, then `nc_vl_command` |
|
|
148
|
+
| Say something out loud on a node or group | `nc_speak`, `nc_speak_group` (short text) |
|
|
149
|
+
| Stop something you are saying | `nc_speak_stop`, `nc_speak_group_stop` |
|
|
150
|
+
| Find out which caller the bridge sees | `nc_identity` |
|
|
151
|
+
| Find out why a local cell will not start | `nc_cell_diagnostics` |
|
|
137
152
|
| Give the human a file | `nc_send_file` or fallback `nc-deliver <file>...` |
|
|
138
153
|
| Read a file the human sent | `nc_inbox` or fallback to the path in the prompt |
|
|
139
154
|
| Send a prompt/command to a session | `nc-send <session> "text"` |
|
|
@@ -150,4 +165,7 @@ work. The setting is also applied to windows created later in that session.
|
|
|
150
165
|
- **Delivering to a guessed session name** → file lands in an orphan folder with no badge. Use `nc-deliver` (it reads the real session).
|
|
151
166
|
- **Sending to an ambiguous cell name** → call `nc_cells` and use the full owner-qualified ID.
|
|
152
167
|
- **Calling `submitted` a completed task** → it is only a transport receipt; require an explicit result callback.
|
|
168
|
+
- **Concluding a cell is stuck because it looks idle** → a terminal interface queues an incoming message while it is busy, which is healthy; text sitting in the composer of a cell that is *not* working is the defect. To tell them apart, read CPU time from the **child** of the pane process: the pane's own pid is often a wrapper that never burns CPU, so measuring it reports "idle" for a cell working at full tilt.
|
|
169
|
+
- **Assuming a node listens on the port you know** → NexusCrew selects a free port per installation, and a peer's remote port is not the port that node listens on locally. Read it from `nexuscrew status` on that node; a health check aimed at the wrong port reports a dead service that is perfectly alive.
|
|
170
|
+
- **Writing a reply into a local inbox directory** → the inbox is per-installation and is not synchronised between nodes. Answering a remote caller by dropping a file in your own inbox reaches nobody; reply through the tool that addressed you.
|
|
153
171
|
- **Treating a dead scroll gesture as a web-terminal bug** → the pane is in the alternate buffer. Check whether it predates the NexusCrew setting or opted out with `alternateScreen:true`; never send raw page keys to a TUI to work around it.
|