@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.
@@ -17,10 +17,20 @@
17
17
  // fallimento, la risposta è `none` col motivo: la Live parte senza
18
18
  // puntamento, comportamento standard. Un `none` non è un errore HTTP.
19
19
  // - MC2: il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
20
- // cella) viaggia su developerInstructions di thread/start quindi
21
- // SOSTITUISCE l'iniezione globale per quella Live (rev4 LC2 emendata da
22
- // rev5 MC2). Se manca non si passa nulla e l'app-server applica la sua
23
- // catena globale/default: nessun gradino è un errore (MC2.4).
20
+ // cella) viaggia su developerInstructions di thread/start e SOSTITUISCE
21
+ // le developer instructions della config per quella Live (rev4 LC2
22
+ // emendata da rev5 MC2). La riga che decide è in codex-rs
23
+ // core/src/config/mod.rs: `developer_instructions.or(cfg.developer_
24
+ // instructions)` — l'override Some scarta il valore di config. R2
25
+ // (2026-08-16, verso corretto dopo audit pre-release): l'identità della
26
+ // cella designata viaggia SEMPRE come intestazione anteposta al campo,
27
+ // anche senza prompt. Il campo NON è additivo: una cella senza
28
+ // LIVE_PROMPT.md, che prima non passava nulla e riceveva le developer
29
+ // instructions della config, ora passa la sola intestazione e QUELLE
30
+ // NON le riceve più. Restano fuori da questa sostituzione AGENTS.md e
31
+ // il world state (fragment user, canale separato) e il prompt base.
32
+ // La via designata per le istruzioni di lavoro della Live è il
33
+ // LIVE_PROMPT.md della cella: viaggia nello stesso campo.
24
34
  // - MC3: il ponte crea le proprie conversazioni con thread/start e non
25
35
  // tocca MAI la thread di una TUI — né turn/start né thread/resume: chi
26
36
  // guarda i metodi visti dal server deve vedere solo initialize,
@@ -62,21 +72,41 @@ const CLIENT_NAME = 'nexuscrew-live-bridge';
62
72
  const ORPHAN_GRACE_MS = 1500;
63
73
 
64
74
  // —— Prompt per-cella (rev4 LC2, nome fisso confermato da Dev 2026-08-15) ——
65
- // Collocazione: filesRoot/cloud-<Cella>/LIVE_PROMPT.md. Tre esiti DISTINTI,
66
- // perché «file non c'è» e «c'è ma non si può leggere» portano chi indaga in
67
- // posti diversi:
68
- // applied:true → il testo va su developerInstructions
69
- // applied:false, reason missing ENOENT: assenza legittima (LC2.3), si
70
- // procede senza, l'app-server applica il
71
- // proprio gradino globale
75
+ // Collocazione: filesRoot/<tmuxSession>/LIVE_PROMPT.md la sessione tmux
76
+ // ESATTA che il roster dichiara per la cella designata, la stessa fonte gia'
77
+ // usata per l'intestazione R2 (identityHeader). NON un prefisso ricostruito a
78
+ // mano: fino al 2026-08-16 questa funzione anteponeva 'cloud-' come default
79
+ // universale quando il cellId non ce l'aveva gia' — su un device che chiama
80
+ // le proprie sessioni con un prefisso diverso il file non veniva MAI trovato,
81
+ // e l'esito era 'missing' ("assenza legittima"): il bug si mascherava
82
+ // esattamente nel ramo che avrebbe dovuto segnalarlo. Bug trovato scrivendo
83
+ // docs/LIVE_PROMPT.md, corretto qui.
84
+ //
85
+ // Quattro esiti DISTINTI, perché «non so nemmeno dove cercare», «ho cercato
86
+ // e non c'è» e «c'è ma non si può leggere» portano chi indaga in posti
87
+ // diversi:
88
+ // applied:true → il testo va su developerInstructions
89
+ // applied:false, reason session-unknown → il roster non dichiara la sessione
90
+ // tmux per questa cella: NESSUN path viene
91
+ // costruito (mai un prefisso indovinato),
92
+ // quindi non si tenta nemmeno la lettura
93
+ // applied:false, reason missing → ENOENT sul path dichiarato: assenza
94
+ // legittima (LC2.3), si procede senza
95
+ // PROMPT (R2: l'intestazione identità
96
+ // viaggia comunque). ATTENZIONE: il campo
97
+ // developerInstructions viene comunque
98
+ // inviato per via dell'intestazione, e il
99
+ // consumer lo SOSTITUISCE alla propria
100
+ // configurazione invece di sommarlo — chi
101
+ // non ha prompt per-cella non riceve le
102
+ // developer instructions globali che
103
+ // riceveva prima (vedi MC2, verificato
104
+ // sulla riga che decide)
72
105
  // applied:false, reason unreadable|empty → presente ma inutilizzabile: va
73
106
  // dichiarato, mai silenziato
74
- function readCellPrompt(filesRoot, cellId) {
75
- // Il canonico per-cella vive in filesRoot/cloud-<Cella>/. L'id cella arriva
76
- // sia senza prefisso (fleet id) sia con (nome sessione tmux): si normalizza
77
- // qui, una volta sola.
78
- const dirName = String(cellId).startsWith('cloud-') ? String(cellId) : `cloud-${cellId}`;
79
- const file = path.join(filesRoot, dirName, 'LIVE_PROMPT.md');
107
+ function readCellPrompt(filesRoot, tmuxSession) {
108
+ if (typeof tmuxSession !== 'string' || !tmuxSession) return { applied: false, reason: 'session-unknown' };
109
+ const file = path.join(filesRoot, tmuxSession, 'LIVE_PROMPT.md');
80
110
  let raw;
81
111
  try {
82
112
  raw = fs.readFileSync(file, 'utf8');
@@ -89,6 +119,22 @@ function readCellPrompt(filesRoot, cellId) {
89
119
  return { applied: true, source: 'LIVE_PROMPT.md', text };
90
120
  }
91
121
 
122
+ // —— Identità della Live (R2, 2026-08-16): la porta il ponte, non il prompt ——
123
+ // Il ponte ha la designazione IN MANO (MC3.4: è la sua condizione di
124
+ // funzionamento) e un prompt può legittimamente mancare (MC2.4): se
125
+ // l'identità dipendesse dal prompt, l'assenza del prompt diventerebbe assenza
126
+ // di identità — è esattamente il difetto visto sul campo (la voce andava a
127
+ // leggere tmux per capire dove si trovava).
128
+ //
129
+ // Il fatto, niente di più: quale cella (id Fleet) e, se il roster la dichiara,
130
+ // la sessione tmux esatta — quella con cui la voce raggiunge i canonici della
131
+ // cella in ~/NexusFiles/<tmuxSession>/. Non un'instruzione di lavoro: quelle
132
+ // vivono nel prompt per-cella, che questa intestazione PRECEDE sempre.
133
+ function identityHeader(cellId, tmuxSession) {
134
+ const sessione = tmuxSession ? ` (sessione tmux ${tmuxSession})` : '';
135
+ return `Live NexusCrew agganciata alla cella ${cellId}${sessione}.`;
136
+ }
137
+
92
138
  // —— Client on-demand del socket di controllo (sezione protocollo sopra) ——
93
139
  // Una sola richiesta per connessione: aperta, handshake, thread/start, chiusa.
94
140
  // Le eventuali notifiche broadcast che arrivano nel frattempo vengono ignorate
@@ -308,7 +354,9 @@ function createLiveBridge({
308
354
  // qualunque altro engine la Live lavora ATTRAVERSO la cella e il ponte non
309
355
  // ha nulla da creare qui.
310
356
  const engine = String(cell.engine || '');
311
- const prompt = readCellPrompt(root, snap.hostCell);
357
+ // Stessa fonte dell'intestazione qui sotto: la sessione tmux che il
358
+ // roster dichiara, mai il cellId ricostruito con un prefisso indovinato.
359
+ const prompt = readCellPrompt(root, cell.tmuxSession);
312
360
 
313
361
  if (!engine.startsWith('codex-vl')) {
314
362
  const out = {
@@ -322,12 +370,21 @@ function createLiveBridge({
322
370
  return out;
323
371
  }
324
372
 
373
+ // R2: l'identità viaggia SEMPRE, anteposta al prompt quando c'è. Il campo
374
+ // non è mai più assente: senza LIVE_PROMPT.md porta la sola intestazione
375
+ // — e poiché il campo SOSTITUISCE le developer instructions della config
376
+ // (vedi MC2: la .or() in config/mod.rs), quella cella non le riceve più.
377
+ const intestazione = identityHeader(snap.hostCell, cell.tmuxSession);
378
+ const developerInstructions = prompt.applied
379
+ ? `${intestazione}\n\n${prompt.text}`
380
+ : intestazione;
381
+
325
382
  let started;
326
383
  try {
327
384
  started = await startThreadOnControlSocket({
328
385
  socketPath: cfg.liveBridgeSocketPath,
329
386
  cwd: cell.cwd,
330
- developerInstructions: prompt.applied ? prompt.text : undefined,
387
+ developerInstructions,
331
388
  timeoutMs: cfg.liveBridgeTimeoutMs,
332
389
  WebSocket,
333
390
  log,
@@ -347,12 +404,15 @@ function createLiveBridge({
347
404
  at: now(),
348
405
  };
349
406
  // LC1.4: il puntamento è visibile lato nostro — log con cella, thread e
350
- // prompt applicato. È il "dirottamento dichiarato" del contratto.
351
- log(`[live-bridge] Live puntata su ${snap.hostCell}: thread ${started.threadId} (cwd ${started.cwd}, prompt ${prompt.applied ? 'per-cella applicato (sostituisce il globale)' : `non applicato (${promptEcho.reason})`})`);
407
+ // prompt applicato. È il "dirottamento dichiarato" del contratto. Il
408
+ // campo SOSTITUISCE le developer instructions della config (MC2, la
409
+ // .or() in config/mod.rs): il log lo dichiara, perché chi lo legge sappia
410
+ // cosa quella Live NON riceve.
411
+ log(`[live-bridge] Live puntata su ${snap.hostCell}: thread ${started.threadId} (cwd ${started.cwd}, identità nell'intestazione, prompt ${prompt.applied ? 'per-cella applicato' : `non applicato (${promptEcho.reason})`}, sostituisce le developer instructions di config)`);
352
412
  return out;
353
413
  }
354
414
 
355
- return { resolveForLive, readCellPrompt: (cellId) => { const { text, ...rest } = readCellPrompt(root, cellId); return rest; } };
415
+ return { resolveForLive, readCellPrompt: (tmuxSession) => { const { text, ...rest } = readCellPrompt(root, tmuxSession); return rest; } };
356
416
  }
357
417
 
358
418
  let cachedVersion = null;
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ // lib/nodes/peer-transitions.js — registra SOLO le transizioni di stato dei
3
+ // peer sul registro diagnostico GIA' esistente (lib/diagnostics/store.js):
4
+ // nessun nuovo store, nessun flusso nuovo, nessun polling in piu'. La salute
5
+ // federata (lib/nodes/health.js) viene gia' sondata a ogni richiesta della UI
6
+ // (/api/nodes, /api/peers) e il risultato veniva buttato via.
7
+ //
8
+ // Misurato il 2026-08-07: un nodo giu' per 25 minuti produceva ZERO record
9
+ // (nextSeq: 1, retained: 0). Senza storia delle transizioni, quando qualcosa
10
+ // cade non si puo' sapere QUANDO e' caduto ne' QUANTE VOLTE.
11
+ //
12
+ // TUNNEL e SERVIZIO restano DUE FATTI DISTINTI, mai un booleano «online»: il
13
+ // caso reale che ha fatto perdere quattro ore era tunnel su + servizio giu'
14
+ // (ECONNRESET, listener vivo ma NexusCrew morto sul dispositivo) contro
15
+ // nessun listener affatto (ECONNREFUSED) — due guasti, due rimedi diversi,
16
+ // e un valore unico li avrebbe fatti cercare nel posto sbagliato.
17
+ //
18
+ // SOLO SULLA TRANSIZIONE: un peer stabile per N sonde produce ZERO record,
19
+ // indipendentemente da quante volte viene sondato. Scrivere a ogni sonda
20
+ // trasformerebbe il registro in rumore e la storia si perderebbe nel volume
21
+ // — peggio di oggi, perche' sembrerebbe di avere i dati.
22
+ //
23
+ // warn, non info: il registro diagnostico scarta 'info'/'debug' quando il
24
+ // verbose non e' acceso (lib/diagnostics/store.js) — e il verbose non si puo'
25
+ // accendere retroattivamente su un incidente gia' successo. Una transizione
26
+ // di peer deve restare visibile anche senza averlo previsto in anticipo, sia
27
+ // che vada verso un guasto sia che torni a posto: solo cosi' si puo'
28
+ // ricostruire QUANDO e' caduto e QUANDO e' tornato, per contare gli episodi.
29
+ const last = new Map(); // nodeName -> { tunnel, service }
30
+
31
+ function tunnelStateOf(health) {
32
+ return health && typeof health.transport === 'string' ? health.transport : 'unknown';
33
+ }
34
+
35
+ // Il servizio si valuta SOLO quando il tunnel e' su: senza trasporto, auth e
36
+ // reachability restano 'unknown' per costruzione (probeHealth non li tocca
37
+ // nel ramo down, vedi lib/proxy/federation.js) — dichiarare qui un servizio
38
+ // "giu'" sarebbe un secondo guasto INVENTATO, non misurato: il tunnel giu'
39
+ // spiega gia' da solo perche' il servizio non e' raggiungibile.
40
+ function serviceStateOf(health) {
41
+ if (!health || health.transport !== 'up') return 'unknown';
42
+ if (health.auth === 'ok' && health.reachability === 'ok') return 'ok';
43
+ if (health.auth === 'failed') return 'auth-failed';
44
+ if (health.reachability === 'failed') return 'unreachable';
45
+ return 'unknown';
46
+ }
47
+
48
+ // nodeName: chiave stabile del peer (node.name in lib/nodes/store.js).
49
+ // health: l'oggetto {transport, auth, reachability, ...} gia' calcolato da
50
+ // lib/nodes/health.js — questa funzione non sonda nulla, consuma soltanto.
51
+ // diagnostics: {record(level, component, code, message, meta)} — vedi
52
+ // lib/diagnostics/store.js. Chiamata ripetutamente con lo stesso stato: dopo
53
+ // la prima volta, silenzio.
54
+ function recordPeerTransition(nodeName, health, diagnostics) {
55
+ if (!nodeName || !diagnostics || typeof diagnostics.record !== 'function') return;
56
+ const tunnel = tunnelStateOf(health);
57
+ const service = serviceStateOf(health);
58
+ const previous = last.get(nodeName);
59
+ last.set(nodeName, { tunnel, service });
60
+ if (!previous) return; // primo probe di questo peer: nessuna "transizione" da niente.
61
+
62
+ const tunnelChanged = previous.tunnel !== tunnel;
63
+ if (tunnelChanged) {
64
+ diagnostics.record('warn', 'peer-health', 'TUNNEL_TRANSITION',
65
+ `${nodeName}: tunnel ${previous.tunnel} -> ${tunnel}`,
66
+ { node: nodeName, state: tunnel });
67
+ }
68
+ // Testabile solo quando il tunnel e' su ORA: se e' appena caduto, il
69
+ // servizio e' passato a 'unknown' come artefatto del tunnel (gia' segnalato
70
+ // sopra), non come un fatto nuovo — quindi qui non si scrive nulla.
71
+ if (tunnel === 'up' && previous.service !== service) {
72
+ diagnostics.record('warn', 'peer-health', 'SERVICE_TRANSITION',
73
+ `${nodeName}: service ${previous.service} -> ${service}`,
74
+ { node: nodeName, state: service });
75
+ }
76
+ }
77
+
78
+ // Per i test: azzera la memoria delle transizioni fra un caso e l'altro.
79
+ function clearPeerTransitions() { last.clear(); }
80
+
81
+ module.exports = { recordPeerTransition, clearPeerTransitions, tunnelStateOf, serviceStateOf };
@@ -89,9 +89,23 @@ function stripLocalTokenQuery(search) {
89
89
  // sessione del control plane, esattamente cio' che l'Authorization rimossa
90
90
  // doveva impedire. E `x-forwarded-*` da un client sono valori che un pannello
91
91
  // potrebbe credere veri. Rilievo di un audit indipendente.
92
- function isStrippedRequestHeader(key) {
92
+ function isStrippedRequestHeader(key, value) {
93
93
  if (HOP_BY_HOP.has(key)) return true;
94
- if (key === 'authorization' || key === 'cookie' || key === 'host') return true;
94
+ if (key === 'cookie' || key === 'host') return true;
95
+ // `authorization` NON si strippa in blocco: lo schema dice di chi e'.
96
+ //
97
+ // `Bearer` e' il nostro token del control plane e non deve mai raggiungere il
98
+ // container — e' la ragione per cui questo strip esiste. Ma un pannello
99
+ // protetto da password risponde `401 WWW-Authenticate: Basic`, il browser
100
+ // chiede le credenziali all'utente e le rimanda: buttando via ANCHE quelle,
101
+ // il pannello ripresentava il prompt all'infinito e il login era impossibile
102
+ // per costruzione. Sono credenziali che l'utente ha inserito PER il pannello,
103
+ // in risposta a una sua richiesta: appartengono a lui.
104
+ //
105
+ // Dichiarato: se qualcuno mette una basic auth propria davanti a NexusCrew,
106
+ // quella credenziale raggiungerebbe il pannello. Non e' il nostro schema di
107
+ // autenticazione e non e' una configurazione che produciamo.
108
+ if (key === 'authorization') return !credenzialeDelPannello(value);
95
109
  // `referer` porterebbe il ticket d'ingresso (viaggiava in query sulla prima
96
110
  // richiesta dell'iframe) fino al pannello: al container non deve arrivare
97
111
  // NESSUNA credenziale, nemmeno di seconda mano.
@@ -101,10 +115,23 @@ function isStrippedRequestHeader(key) {
101
115
  return false;
102
116
  }
103
117
 
118
+ // ELENCO CHIUSO di cio' che si inoltra, non di cio' che si toglie: un valore
119
+ // sconosciuto non deve poter passare per omissione. Sono gli schemi con cui un
120
+ // server web chiede le credenziali al browser — quelli che l'utente puo' aver
121
+ // inserito in risposta a un `401` del pannello. Tutto il resto, `Bearer`
122
+ // compreso, resta di qua.
123
+ const SCHEMI_DEL_PANNELLO = /^(basic|digest|ntlm|negotiate)\s/i;
124
+
125
+ function credenzialeDelPannello(value) {
126
+ const primo = Array.isArray(value) ? value[0] : value;
127
+ if (typeof primo !== 'string') return false;
128
+ return SCHEMI_DEL_PANNELLO.test(primo.trim());
129
+ }
130
+
104
131
  function forwardHeaders(headers, targetHost) {
105
132
  const out = {};
106
133
  for (const [k, v] of Object.entries(headers || {})) {
107
- if (isStrippedRequestHeader(k.toLowerCase())) continue;
134
+ if (isStrippedRequestHeader(k.toLowerCase(), v)) continue;
108
135
  out[k] = v;
109
136
  }
110
137
  // L'host deve essere quello della destinazione: un pannello che genera
package/lib/server.js CHANGED
@@ -19,6 +19,7 @@ const { loadOrCreateToken, verify } = require('./auth/token.js');
19
19
  const { requireToken, bearerFrom } = require('./auth/middleware.js');
20
20
  const { filesRoutes } = require('./files/routes.js');
21
21
  const { createOutboxWatcher } = require('./files/watcher.js');
22
+ const { leggiTelemetria } = require('./files/telemetry.js');
22
23
  const VERSION = require('../package.json').version;
23
24
  const { transcribe } = require('./voice/transcribe.js');
24
25
  const { selectProvider } = require('./fleet/provider.js');
@@ -32,6 +33,7 @@ const { fsRoutes } = require('./fs/routes.js');
32
33
  const nodesStore = require('./nodes/store.js');
33
34
  const nodesTunnel = require('./nodes/tunnel.js');
34
35
  const nodesHealth = require('./nodes/health.js');
36
+ const { recordPeerTransition } = require('./nodes/peer-transitions.js');
35
37
  const nodesInventory = require('./nodes/inventory.js');
36
38
  const { createReverseSlotListeners } = require('./nodes/reverse-slot-listeners.js');
37
39
  const reverseRotation = require('./nodes/reverse-rotation.js');
@@ -753,6 +755,10 @@ function createServer(opts = {}) {
753
755
  status: working ? (s.status || sample?.status || '') : '',
754
756
  outbox: sum[s.name] || { count: 0, latest: 0 },
755
757
  preview: sample?.preview ?? null,
758
+ // Contesto libero e tier usati, dove la cella li pubblica (celle
759
+ // Claude via statusline). Null per tutte le altre — assenza
760
+ // legittima, non un trattino: la riga resta com'era.
761
+ telemetry: leggiTelemetria(cfg.filesRoot, s.name),
756
762
  };
757
763
  }));
758
764
  res.json({ sessions: enriched });
@@ -976,6 +982,11 @@ function createServer(opts = {}) {
976
982
  });
977
983
  const nodes = view.nodes.map((n, i) => {
978
984
  const h = healths[i] || null;
985
+ // La sonda gia' avviene per servire questa risposta: qui si registra
986
+ // SOLO se il risultato e' diverso dall'ultimo noto per questo peer
987
+ // (lib/nodes/peer-transitions.js) — nessun probe in piu', nessun
988
+ // record se il peer resta invariato.
989
+ recordPeerTransition(n.name, h, diagnostics);
979
990
  return { ...n, tunnel: nodesHealth.tunnelFromHealth(h), health: h };
980
991
  });
981
992
  res.json({ nodeId: view.nodeId, nodes });
@@ -994,6 +1005,10 @@ function createServer(opts = {}) {
994
1005
  });
995
1006
  const extras = new Map(direct.map((node, index) => {
996
1007
  const health = healths[index] || null;
1008
+ // Stessa registrazione di /nodes: la Map di stato di
1009
+ // peer-transitions.js e' condivisa per nome peer, quindi una
1010
+ // transizione gia' vista da /nodes non produce un secondo record qui.
1011
+ recordPeerTransition(node.name, health, diagnostics);
997
1012
  return [node.name, { health, tunnel: nodesHealth.tunnelFromHealth(health) }];
998
1013
  }));
999
1014
  const topology = await federation.collectLocalTopology({
@@ -6,7 +6,7 @@ const os = require('node:os');
6
6
  const path = require('node:path');
7
7
  const { execFileSync } = require('node:child_process');
8
8
  const {
9
- PACKAGE_NAME, parseVersion, scrubError, pidAlive, adoptUpdateLock, releaseUpdateLock, readState, writeState,
9
+ PACKAGE_NAME, parseVersion, scrubError, adoptUpdateLock, releaseUpdateLock, readState, writeState,
10
10
  stableRuntimeDir,
11
11
  } = require('./core.js');
12
12
 
@@ -36,44 +36,45 @@ 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.
39
+ // IL PUNTO UNICO DEL RIAVVIO, attraversato anche qui. Fino a qui questo
40
+ // ramo aveva una sua copia di ogni passo: tunnel fermati con un
41
+ // try/copy proprio, abbattimento con killPidfile diretto (senza l'attesa
42
+ // dell'uscita che il riavvio manuale fa), attesa porta inline. Due
43
+ // copie divergono: la stessa divergenza è stata trovata TRE volte, una
44
+ // per guasto — l'ultima misurata sul campo (2026-08-07): riavvii a mano
45
+ // → peer su 2/2, riavvii per aggiornamento → peer giù 2/2, la differenza
46
+ // era UNA riga. Ora i tunnel si fermano PRIMA dallo STESSO ingresso
47
+ // (`fermaTunnelPrimaDiRiavviare`, con la policy best-effort di questo
48
+ // chiamante: il compito principale è aggiornare) e il vecchio processo
49
+ // si abbatte con la STESSA `stopPortableRuntime` del manuale — kill e
50
+ // attesa dell'uscita incluse.
44
51
  //
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
- }
52
+ // COSA PRODUCEVA non fermarli, osservato sul campo: il servizio nuovo
53
+ // trovava un supervisore ancora vivo che non riusciva ad attribuirsi e
54
+ // non lo fermava ne avviava uno suo (`lib/nodes/tunnel.js`:
55
+ // «unattributable existing supervisor»): il canale inverso restava
56
+ // appeso a un orfano e il peer risultava giù finché qualcuno non
57
+ // interveniva a mano.
58
+ commands.fermaTunnelPrimaDiRiavviare({
59
+ home, platform,
60
+ ...(opts.stopTunnelsImpl ? { stopTunnelsImpl: opts.stopTunnelsImpl } : {}),
61
+ ...(opts.log ? { log: opts.log } : {}),
62
+ });
66
63
  const pidPath = pidf.defaultPidfilePath(home);
67
64
  const meta = pidf.readPidfile(pidPath);
68
65
  if (meta && pidf.isAlive(meta)) {
69
- const stopped = pidf.killPidfile(pidPath);
66
+ const stopped = (opts.stopPortableImpl || commands.stopPortableRuntime)({ home, platform });
70
67
  if (!stopped.killed) throw new Error(`restart portatile fallito: ${stopped.reason || 'processo non arrestato'}`);
68
+ // Il pid è già stato atteso DENTRO stopPortableRuntime (waitForPidExit);
69
+ // resta la PORTA, che il kernel può trattenere oltre la morte del
70
+ // processo: un bind nuovo su porta occupata fa cadere il runtime sul
71
+ // fallback (server.js EADDRINUSE) — una porta DIVERSA da quella
72
+ // configurata, con i peer accoppiati rifiutati. Si aspetta la vera
73
+ // liberazione, non la morte del pid.
71
74
  const wait = opts.waitImpl || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
72
75
  let released = false;
73
76
  for (let i = 0; i < 60; i += 1) {
74
- const dead = !(opts.pidAliveImpl || pidAlive)(stopped.pid);
75
- const free = await (opts.portAvailableImpl || commands.portAvailable)(port);
76
- if (dead && free) { released = true; break; }
77
+ if (await (opts.portAvailableImpl || commands.portAvailable)(port)) { released = true; break; }
77
78
  await wait(100);
78
79
  }
79
80
  if (!released) throw new Error(`restart portatile fallito: porta ${port} non liberata`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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": {
@@ -83,6 +83,14 @@ owning node, not a retry from the requester.
83
83
  with nothing designated still answers `GET` with `hostCell: null` — a valid
84
84
  state, distinct from "not permitted to ask".
85
85
 
86
+ ## The per-cell voice prompt
87
+
88
+ Once a session lands on a host cell, a native Live (engine `codex-vl*`)
89
+ always sends `thread/start` an identity header naming the designated cell
90
+ and its exact tmux session, and appends a per-cell prompt file's text after
91
+ it when one exists. See [The Live per-cell prompt](../../docs/LIVE_PROMPT.md)
92
+ for where the file goes and the ready-to-copy IT/EN/ES templates.
93
+
86
94
  ## Dependencies
87
95
 
88
96
  **Bundled:** this is a NexusCrew core feature. No external MCP companion or