@mmmbuto/nexuscrew 0.9.2 → 0.9.4
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 +157 -0
- package/frontend/dist/assets/index-CbMytNmg.css +32 -0
- package/frontend/dist/assets/index-DFI1J-u5.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +11 -3
- package/lib/cli/init.js +18 -3
- package/lib/cli/pidfile.js +27 -1
- package/lib/cli/runtime-lifecycle.js +31 -1
- package/lib/files/telemetry.js +89 -0
- package/lib/fleet/builtin.js +218 -64
- package/lib/fleet/definitions.js +226 -0
- package/lib/live-host/bridge.js +82 -22
- package/lib/nodes/peer-transitions.js +81 -0
- package/lib/proxy/panel-proxy.js +30 -3
- package/lib/server.js +15 -0
- package/lib/update/runner.js +32 -31
- package/package.json +1 -1
- package/skills/live/SKILL.md +8 -0
- package/frontend/dist/assets/index-0vuhL1YP.css +0 -32
- package/frontend/dist/assets/index-uRh_hSop.js +0 -93
package/frontend/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DFI1J-u5.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CbMytNmg.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.4"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -23,7 +23,7 @@ const peering = require('../nodes/peering.js');
|
|
|
23
23
|
// Service/portable runtime ownership + start/stop lifecycle (Phase 3 extraction).
|
|
24
24
|
const runtimeLifecycle = require('./runtime-lifecycle.js');
|
|
25
25
|
const {
|
|
26
|
-
start, stop, isServiceRunning, bootState, stopManagedTunnels,
|
|
26
|
+
start, stop, isServiceRunning, bootState, stopManagedTunnels, fermaTunnelPrimaDiRiavviare,
|
|
27
27
|
managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
|
|
28
28
|
refusePairedPortRelocation, stopPortableRuntime, unlinkRegular,
|
|
29
29
|
} = runtimeLifecycle;
|
|
@@ -350,12 +350,18 @@ function restart(opts = {}) {
|
|
|
350
350
|
if (!portable.killed) return { platform, owner: before.owner, restarted: false, reason: portable.reason };
|
|
351
351
|
}
|
|
352
352
|
try {
|
|
353
|
+
// I tunnel si fermano PRIMA, dal punto unico condiviso con ogni altro
|
|
354
|
+
// percorso di riavvio. Qui la policy è fail-closed: se il passo non
|
|
355
|
+
// riesce, il riavvio gestito non parte — un tunnel vivo sopra un
|
|
356
|
+
// servizio riavviato è il guasto misurato sul campo (2026-08-07).
|
|
357
|
+
const tunnels = fermaTunnelPrimaDiRiavviare(opts);
|
|
358
|
+
if (tunnels.error) {
|
|
359
|
+
return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: `stop tunnel: ${tunnels.error}` };
|
|
360
|
+
}
|
|
353
361
|
if (platform === 'linux') {
|
|
354
|
-
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
355
362
|
execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
|
|
356
363
|
log('restart: systemctl --user restart nexuscrew');
|
|
357
364
|
} else {
|
|
358
|
-
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
359
365
|
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
360
366
|
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
361
367
|
log(`restart: launchctl kickstart -k ${label}`);
|
|
@@ -1505,6 +1511,8 @@ module.exports = {
|
|
|
1505
1511
|
isServiceRunning, readRoles,
|
|
1506
1512
|
bootState, bootOn, bootOff, quickSummary,
|
|
1507
1513
|
stopManagedTunnels,
|
|
1514
|
+
fermaTunnelPrimaDiRiavviare,
|
|
1515
|
+
stopPortableRuntime,
|
|
1508
1516
|
managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
|
|
1509
1517
|
runFleetBoot, dispatchFleetBoot,
|
|
1510
1518
|
};
|
package/lib/cli/init.js
CHANGED
|
@@ -17,7 +17,7 @@ const {
|
|
|
17
17
|
generateFleetService, installFleetService, migrationGate,
|
|
18
18
|
selectProviderModeSync, fleetFileMode,
|
|
19
19
|
} = require('./fleet-service.js');
|
|
20
|
-
const {
|
|
20
|
+
const { aggiornaDefinizioni } = require('../fleet/definitions.js');
|
|
21
21
|
const { defaultDefinitions } = require('../fleet/managed.js');
|
|
22
22
|
const { commandExists, resolveCommand } = require('./path.js');
|
|
23
23
|
|
|
@@ -83,8 +83,23 @@ function ensureFleetDefaults(opts = {}) {
|
|
|
83
83
|
if (e.code !== 'ENOENT') throw e;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// Fra il controllo qui sopra e la scrittura c'e' una finestra: se un altro
|
|
87
|
+
// processo crea le definizioni nel frattempo, scrivere i default sopra le sue
|
|
88
|
+
// significa cancellare una configurazione intera — non perdere una modifica.
|
|
89
|
+
// La creazione avviene quindi sotto lock, con l'esistenza RIVERIFICATA
|
|
90
|
+
// dentro: `seMancante` viene chiamata solo se il file davvero non c'e'.
|
|
91
|
+
// `propaga` perche' `created` dica il vero: senza, una rinuncia del lock
|
|
92
|
+
// restituiva lo stato riletto — truthy — e l'init dichiarava `created: true`
|
|
93
|
+
// per un file che non aveva creato.
|
|
94
|
+
let creato = null;
|
|
95
|
+
try {
|
|
96
|
+
creato = aggiornaDefinizioni(fleetDefsPath, () => null, {
|
|
97
|
+
seMancante: () => defaultDefinitions(),
|
|
98
|
+
propaga: true,
|
|
99
|
+
log: opts.log,
|
|
100
|
+
});
|
|
101
|
+
} catch (_) { creato = null; }
|
|
102
|
+
return { path: fleetDefsPath, created: Boolean(creato), enabled: true };
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
// Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
|
package/lib/cli/pidfile.js
CHANGED
|
@@ -112,12 +112,26 @@ function cmdMatches(savedCmd, liveCmd) {
|
|
|
112
112
|
return liveCmd.includes(savedCmd) || savedCmd.includes(liveCmd);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
// true se il pid appartiene a questo UID
|
|
115
|
+
// true se il pid appartiene a questo UID, la NASCITA è ancora quella attestata
|
|
116
|
+
// dal pidfile e il cmd matcha (o non è verificabile).
|
|
116
117
|
// EPERM is deliberately false: NexusCrew must neither adopt nor signal a
|
|
117
118
|
// process owned by another Android/Linux user.
|
|
119
|
+
// La nascita (processStart, già scritta da writePidfile) decide PRIMA del cmd:
|
|
120
|
+
// cmdMatches è per inclusioni, quindi un comando più lungo del salvato matcha
|
|
121
|
+
// — due supervisor dello stesso tunnel, due serve, il restart di ieri. Se il
|
|
122
|
+
// numero è nato in un istante diverso da quello attestato, il proprietario è
|
|
123
|
+
// morto e il numero è di un altro: il pidfile è stale anche se il cmd «quadra».
|
|
124
|
+
// Non calcolabile (meta senza processStart: pidfile vecchio o /proc nascosto;
|
|
125
|
+
// lettura che ora non riesce: macOS senza ps) → nel dubbio vale il cmd, come
|
|
126
|
+
// sempre: mai dichiarare morto un vivo. Stessa identità e stessa asimmetria
|
|
127
|
+
// del lock delle definizioni (pid + nascita; il falso-morto non esiste).
|
|
118
128
|
function isAlive(meta, impl = {}) {
|
|
119
129
|
if (!meta || !Number.isFinite(meta.pid)) return false;
|
|
120
130
|
if (pidOwnership(meta.pid, impl.killImpl || process.kill) !== 'owned') return false;
|
|
131
|
+
if (meta.processStart) {
|
|
132
|
+
const liveStart = (impl.readProcessStartImpl || readProcessStart)(meta.pid);
|
|
133
|
+
if (liveStart && liveStart !== meta.processStart) return false; // riassegnato
|
|
134
|
+
}
|
|
121
135
|
if (meta.cmd) {
|
|
122
136
|
const live = (impl.readCmdlineImpl || readCmdline)(meta.pid);
|
|
123
137
|
if (live) return cmdMatches(meta.cmd, live);
|
|
@@ -171,6 +185,18 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
|
|
|
171
185
|
return { killed: false, reason: 'pid reuse (cmd mismatch)', liveCmd: live };
|
|
172
186
|
}
|
|
173
187
|
}
|
|
188
|
+
// PID reuse che il cmd NON vede: due processi con lo stesso comando (o un
|
|
189
|
+
// comando che lo contiene). La nascita li distingue: se il numero è nato in
|
|
190
|
+
// un istante diverso da quello attestato, il segnale andrebbe al processo
|
|
191
|
+
// SBAGLIATO — vivo, nostro, magari legittimo. Non si segnala; il pidfile è
|
|
192
|
+
// stale e si toglie (allowLive: garanzia verificata QUI, come dopo il kill).
|
|
193
|
+
if (meta.processStart) {
|
|
194
|
+
const liveStart = (impl.readProcessStartImpl || readProcessStart)(meta.pid);
|
|
195
|
+
if (liveStart && liveStart !== meta.processStart) {
|
|
196
|
+
removePidfile(p, { allowLive: true });
|
|
197
|
+
return { killed: false, reason: 'pid reuse (start mismatch)' };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
174
200
|
try {
|
|
175
201
|
killImpl(meta.pid, signal);
|
|
176
202
|
// allowLive: il segnale e' partito verso il pid VERIFICATO del file (cmd
|
|
@@ -103,6 +103,31 @@ function stopManagedTunnels(opts = {}) {
|
|
|
103
103
|
return stopped;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// IL PASSO «I TUNNEL SI FERMANO PRIMA», una volta sola. Ogni percorso di
|
|
107
|
+
// riavvio — commands.restart (sincrona, mano), stop() e il ramo portatile
|
|
108
|
+
// dell'aggiornamento automatico — attraversa QUESTO ingresso: è il posto
|
|
109
|
+
// unico dove un domani si aggiunge un passo al riavvio, invece di scriverlo
|
|
110
|
+
// in una copia e dimenticarlo nell'altra (la divergenza trovata tre volte,
|
|
111
|
+
// una per guasto).
|
|
112
|
+
//
|
|
113
|
+
// Non lancia MAI: il passo è intrinsecamente best-effort (un tunnel che non
|
|
114
|
+
// si ferma non deve lasciare il riavvio a metà in modo diverso a seconda del
|
|
115
|
+
// chiamante) — ma non tace: l'esito torna a chi chiama, e la POLICY (abort
|
|
116
|
+
// fail-closed per il riavvio gestito, proseguire per l'aggiornamento il cui
|
|
117
|
+
// compito principale è aggiornare) resta a chi la possiede.
|
|
118
|
+
function fermaTunnelPrimaDiRiavviare(opts = {}) {
|
|
119
|
+
const log = opts.log || (() => {});
|
|
120
|
+
try {
|
|
121
|
+
const stopped = (opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
122
|
+
return { stopped, error: null };
|
|
123
|
+
} catch (e) {
|
|
124
|
+
const reason = String(e && e.message ? e.message : e);
|
|
125
|
+
// Tacerlo lo renderebbe irrintracciabile la prossima volta.
|
|
126
|
+
log(`stop tunnel non riuscito — ${reason}`);
|
|
127
|
+
return { stopped: [], error: reason };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
106
131
|
function refusePairedPortRelocation(opts, from, to) {
|
|
107
132
|
if (Number(from) === Number(to)) return;
|
|
108
133
|
const { configDir } = urlmod.resolvePaths(opts);
|
|
@@ -240,7 +265,11 @@ function stop(opts = {}) {
|
|
|
240
265
|
|
|
241
266
|
// Detached tunnel supervisors are outside both server owners. A real stop
|
|
242
267
|
// closes them once; the next start restores only autostart:true links.
|
|
243
|
-
|
|
268
|
+
// Attraverso lo STESSO ingresso del riavvio (fermaTunnelPrimaDiRiavviare):
|
|
269
|
+
// prima un fallimento qui sfuggiva come eccezione non gestita al chiamante
|
|
270
|
+
// sincrono; ora è esito del stop, come ogni altro errore qui sopra.
|
|
271
|
+
const tunnels = fermaTunnelPrimaDiRiavviare(opts);
|
|
272
|
+
if (tunnels.error) errors.push(`stop tunnel: ${tunnels.error}`);
|
|
244
273
|
if (platform === 'termux') {
|
|
245
274
|
try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
|
|
246
275
|
}
|
|
@@ -286,6 +315,7 @@ function unlinkRegular(target) {
|
|
|
286
315
|
module.exports = {
|
|
287
316
|
start,
|
|
288
317
|
stopManagedTunnels,
|
|
318
|
+
fermaTunnelPrimaDiRiavviare,
|
|
289
319
|
refusePairedPortRelocation,
|
|
290
320
|
managedRuntimeState,
|
|
291
321
|
portableRuntimeState,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Telemetria per-cella: contesto LIBERO e tier 5h/7d USATI, letti dal file che
|
|
3
|
+
// la statusline di Claude Code scrive in <root>/<sessione>/telemetry.json
|
|
4
|
+
// (snippet documentato in docs/STATUSLINE_TELEMETRY.md, NON applicato: la
|
|
5
|
+
// statusline e' dell'operatore). Il verso dei due dati e' OPPOSTO e i nomi del file lo
|
|
6
|
+
// portano scritto dentro: `contextFreePct` e' quanto RESTA, `tier*UsedPct` e'
|
|
7
|
+
// quanto e' STATO CONSUMATO. Confondere i versi produce una riga che dice il
|
|
8
|
+
// contrario del vero e fa prendere la decisione opposta a quella giusta.
|
|
9
|
+
//
|
|
10
|
+
// Tre regole non opinabili, tutte implementate qui:
|
|
11
|
+
// 1. TIMESTAMP OBBLIGATORIO: oltre MASSIMA_ETA_MS il dato e' morto e viene
|
|
12
|
+
// restituito null — un numero stantio che sembra fresco e' peggio di un
|
|
13
|
+
// numero assente.
|
|
14
|
+
// 2. ASSENZA LEGITTIMA: le celle non-Claude (codex-vl, agy, grok, shell) non
|
|
15
|
+
// hanno quella statusline e non avranno mai questo file. File assente =
|
|
16
|
+
// null, e la riga della lista resta com'era: niente campo, niente
|
|
17
|
+
// trattino, niente «n/d».
|
|
18
|
+
// 3. LETTURA TOLLERANTE: file illeggibile, JSON rotto, campi mancanti o
|
|
19
|
+
// fuori contratto — si degrada a null senza mai far fallire la lista.
|
|
20
|
+
//
|
|
21
|
+
// CONTRATTO del file: valori INTERI 0..100 (gia' percentuali — la
|
|
22
|
+
// normalizzazione frazione→percentuale e' compito di chi scrive, vedi lo
|
|
23
|
+
// snippet). Il lettore accetta SOLO interi: una frazione scritta per errore
|
|
24
|
+
// (0.5 che voleva essere 50%) viene rifiutata, non arrotondata a 1% — un
|
|
25
|
+
// numero sbagliato mostrato con sicurezza e' il difetto che conta, meglio
|
|
26
|
+
// nessun numero.
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
// La statusline aggiorna a ogni evento del modello: in una cella viva il file
|
|
32
|
+
// e' sempre piu' fresco di cosi'. Cinque minuti coprono una pausa pranzo senza
|
|
33
|
+
// mostrare come attuale il dato di ieri.
|
|
34
|
+
const MASSIMA_ETA_MS = 5 * 60 * 1000;
|
|
35
|
+
// La soglia guarda in ENTRAMBI i versi. Un ts nel futuro farebbe `ora - ts`
|
|
36
|
+
// negativo: la differenza non supera MAI la massima eta' e il dato resterebbe
|
|
37
|
+
// «fresco» per sempre — un orologio avanti, o uno ts scritto male, e la riga
|
|
38
|
+
// mostra un numero morto che non scadra' mai. Due minuti di skew sono il
|
|
39
|
+
// margine che un orologio legittimamente sforato puo' avere; oltre, il ts e'
|
|
40
|
+
// rotto e il dato non esiste.
|
|
41
|
+
const FUTURO_TOLLERATO_MS = 2 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
const NOME_FILE = 'telemetry.json';
|
|
44
|
+
|
|
45
|
+
// Accetta SOLO interi 0..100 gia' numeri. Tutto il resto e' fuori contratto
|
|
46
|
+
// e non viene mostrato — in particolare null e i booleani: `Number(null)` e'
|
|
47
|
+
// 0 e `Number(true)` e' 1, e un campo assente letto come «0% usato» e'
|
|
48
|
+
// esattamente il numero sbagliato-mostrato-con-sicurezza che questo modulo
|
|
49
|
+
// esiste per evitare.
|
|
50
|
+
function percentualeIntera(valore) {
|
|
51
|
+
if (typeof valore !== 'number' || !Number.isInteger(valore)) return null;
|
|
52
|
+
if (valore < 0 || valore > 100) return null;
|
|
53
|
+
return valore;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Legge la telemetria di una sessione. Ritorna `{ ts, contextFreePct?,
|
|
58
|
+
* tier5hUsedPct?, tier7dUsedPct? }` con solo i campi validi, oppure null per
|
|
59
|
+
* assenza legittima, dato stantio o qualsiasi rotture. Non lancia MAI.
|
|
60
|
+
*/
|
|
61
|
+
function leggiTelemetria(root, sessione, ora = Date.now()) {
|
|
62
|
+
try {
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = fs.readFileSync(path.join(root, String(sessione), NOME_FILE), 'utf8');
|
|
66
|
+
} catch (_) {
|
|
67
|
+
return null; // assente (o non leggibile): cella non-Claude o primo avvio
|
|
68
|
+
}
|
|
69
|
+
const dato = JSON.parse(raw);
|
|
70
|
+
if (!dato || typeof dato !== 'object' || Array.isArray(dato)) return null;
|
|
71
|
+
const ts = Number(dato.ts);
|
|
72
|
+
// Senza timestamp non c'e' freschezza da verificare: il dato non esiste.
|
|
73
|
+
if (!Number.isFinite(ts)) return null;
|
|
74
|
+
if (ora - ts > MASSIMA_ETA_MS) return null; // stantio = assente
|
|
75
|
+
if (ts - ora > FUTURO_TOLLERATO_MS) return null; // ts rotto: «fresco per sempre» non e' fresco
|
|
76
|
+
const campi = {};
|
|
77
|
+
const libero = percentualeIntera(dato.contextFreePct);
|
|
78
|
+
const t5 = percentualeIntera(dato.tier5hUsedPct);
|
|
79
|
+
const t7 = percentualeIntera(dato.tier7dUsedPct);
|
|
80
|
+
if (libero !== null) campi.contextFreePct = libero;
|
|
81
|
+
if (t5 !== null) campi.tier5hUsedPct = t5;
|
|
82
|
+
if (t7 !== null) campi.tier7dUsedPct = t7;
|
|
83
|
+
return Object.keys(campi).length ? { ts, ...campi } : null;
|
|
84
|
+
} catch (_) {
|
|
85
|
+
return null; // JSON rotto o altro: la lista non fallisce per questo
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { leggiTelemetria, MASSIMA_ETA_MS, FUTURO_TOLLERATO_MS, NOME_FILE };
|