@mmmbuto/nexuscrew 0.9.13 → 0.9.15

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.
@@ -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-BBCrZoxR.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-keXh4CAm.css">
14
+ <script type="module" crossorigin src="/assets/index-ToJ5arRI.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CprIQlC5.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.12"}
1
+ {"version":"0.9.15"}
package/lib/config.js CHANGED
@@ -67,6 +67,14 @@ function baseDefaults() {
67
67
  // MC1.5: limite dichiarato per OGNI fase del ponte (GET designazione e
68
68
  // sessione sul socket). Oltre questo la Live parte senza puntamento.
69
69
  liveBridgeTimeoutMs: 1500,
70
+ // PTY grace is finite and configurable for mobile handovers. The caps keep
71
+ // a small host from retaining an unbounded number of disconnected sessions.
72
+ ptyGraceMs: 30000,
73
+ ptyGraceMaxSessions: 8,
74
+ ptyGraceMaxMemoryBytes: 8 * 1024,
75
+ // Un singolo timeout del probe può essere jitter mobile: servono tre
76
+ // fallimenti consecutivi prima di dichiarare il peer irraggiungibile.
77
+ nodeHealthFailureThreshold: 3,
70
78
  };
71
79
  }
72
80
 
@@ -119,6 +127,10 @@ function envOverrides() {
119
127
  }
120
128
  if (process.env.NEXUSCREW_LIVE_BRIDGE_SOCKET) e.liveBridgeSocketPath = process.env.NEXUSCREW_LIVE_BRIDGE_SOCKET;
121
129
  if (process.env.NEXUSCREW_LIVE_BRIDGE_TIMEOUT_MS) e.liveBridgeTimeoutMs = Number(process.env.NEXUSCREW_LIVE_BRIDGE_TIMEOUT_MS);
130
+ if (process.env.NEXUSCREW_PTY_GRACE_MS) e.ptyGraceMs = Number(process.env.NEXUSCREW_PTY_GRACE_MS);
131
+ if (process.env.NEXUSCREW_PTY_GRACE_MAX_SESSIONS) e.ptyGraceMaxSessions = Number(process.env.NEXUSCREW_PTY_GRACE_MAX_SESSIONS);
132
+ if (process.env.NEXUSCREW_PTY_GRACE_MAX_MEMORY_BYTES) e.ptyGraceMaxMemoryBytes = Number(process.env.NEXUSCREW_PTY_GRACE_MAX_MEMORY_BYTES);
133
+ if (process.env.NEXUSCREW_NODE_HEALTH_FAILURE_THRESHOLD) e.nodeHealthFailureThreshold = Number(process.env.NEXUSCREW_NODE_HEALTH_FAILURE_THRESHOLD);
122
134
  return e;
123
135
  }
124
136
 
@@ -32,10 +32,10 @@
32
32
  // La via designata per le istruzioni di lavoro della Live è il
33
33
  // LIVE_PROMPT.md della cella: viaggia nello stesso campo.
34
34
  // - MC3: il ponte crea le proprie conversazioni con thread/start e non
35
- // tocca MAI la thread di una TUI — né turn/start né thread/resume: chi
36
- // guarda i metodi visti dal server deve vedere solo initialize,
37
- // initialized e thread/start. Per questo l'aggancio funziona anche su una
38
- // cella che sta già processando un turno: conversazioni separate, nessuna
35
+ // tocca MAI la thread di una TUI — né turn/start né thread/resume. La sonda
36
+ // thread/read e' separata e sola lettura: non modifica il thread ponte ne'
37
+ // quello della TUI. Per questo l'aggancio funziona anche su una cella che
38
+ // sta già processando un turno: conversazioni separate, nessuna
39
39
  // interruzione (rev1 HC2/rev2 JC4).
40
40
  // - MC3.3: la connessione al socket di controllo è ON-DEMAND (connect →
41
41
  // handshake → thread/start → close), mai permanente: la fuga notifiche
@@ -49,7 +49,8 @@
49
49
  // WebSocket (text frame) sopra unix socket, JSON-RPC. Handshake: request
50
50
  // `initialize` → response {userAgent, codexHome} → notifica `initialized`
51
51
  // (senza params). Poi `thread/start` {cwd, developerInstructions?} → response
52
- // {thread:{id}, cwd}. Il socket è 0600 dell'utente: il confine è quello
52
+ // {thread:{id}, cwd}, oppure `thread/read` {threadId, includeTurns:false} per
53
+ // leggere il runtime. Il socket è 0600 dell'utente: il confine è quello
53
54
  // (MC1.3), non c'è autenticazione applicativa.
54
55
 
55
56
  const fs = require('node:fs');
@@ -70,6 +71,98 @@ const CLIENT_NAME = 'nexuscrew-live-bridge';
70
71
  // chiudiamo. Breve di proposito — non allunga la risposta a chi ha chiesto il
71
72
  // ponte, che e' gia' stata data.
72
73
  const ORPHAN_GRACE_MS = 1500;
74
+ const THREAD_STATUS_CACHE_MS = 1500;
75
+
76
+ function normalizedThreadStatus(status) {
77
+ const type = typeof status === 'string' ? status : status && status.type;
78
+ switch (String(type || '').toLowerCase()) {
79
+ case 'notloaded': return 'absent';
80
+ case 'idle': return 'present';
81
+ case 'active': return 'active';
82
+ // A server-reported system error is not evidence that the thread is absent.
83
+ case 'systemerror': return 'unknown';
84
+ default: return null;
85
+ }
86
+ }
87
+
88
+ // Query on-demand del runtime del thread. La connessione e' dedicata a una
89
+ // sola lettura: ogni uscita, inclusi timeout ed errore di protocollo, chiude il
90
+ // WebSocket prima di risolvere la Promise. Il valore restituito e' volutamente
91
+ // piu' stretto del protocollo: presente/attivo/assente, oppure il chiamante
92
+ // classifica il fallimento come unknown.
93
+ function queryThreadStatusOnControlSocket({
94
+ socketPath, threadId, timeoutMs, WebSocket = defaultWebSocket(),
95
+ }) {
96
+ return new Promise((resolve, reject) => {
97
+ let settled = false;
98
+ let nextId = 0;
99
+ let ws;
100
+ const close = (force = false) => {
101
+ try {
102
+ if (!ws) return;
103
+ if (!force && ws.readyState === WebSocket.OPEN) ws.close(1000); else ws.terminate();
104
+ } catch (_) { /* best effort: il socket verra' raccolto dal peer */ }
105
+ };
106
+ const done = (error, value) => {
107
+ if (settled) return;
108
+ settled = true;
109
+ clearTimeout(timer);
110
+ close(!!error);
111
+ if (error) reject(error); else resolve(value);
112
+ };
113
+ const timer = setTimeout(() => {
114
+ done(Object.assign(new Error('control socket thread/read timeout'), { code: 'ETIMEOUT' }));
115
+ }, timeoutMs);
116
+
117
+ try {
118
+ ws = new WebSocket(`ws+unix://${socketPath}:/`, { handshakeTimeout: timeoutMs });
119
+ } catch (e) {
120
+ done(Object.assign(new Error(`control socket: ${e.message}`), { code: 'ESOCKET' }));
121
+ return;
122
+ }
123
+
124
+ const send = (obj) => ws.send(JSON.stringify(obj));
125
+ const request = (method, params) => new Promise((res, rej) => {
126
+ const id = ++nextId;
127
+ ws.pending = ws.pending || new Map();
128
+ ws.pending.set(id, { res, rej });
129
+ send({ jsonrpc: '2.0', id, method, params });
130
+ });
131
+
132
+ ws.on('open', async () => {
133
+ if (settled) { close(); return; }
134
+ try {
135
+ await request('initialize', {
136
+ clientInfo: { name: CLIENT_NAME, title: 'NexusCrew Thread Status', version: bridgeVersion() },
137
+ capabilities: { experimentalApi: true },
138
+ });
139
+ send({ jsonrpc: '2.0', method: 'initialized' });
140
+ const out = await request('thread/read', { threadId, includeTurns: false });
141
+ const value = normalizedThreadStatus(out && out.thread && out.thread.status);
142
+ if (!value) throw Object.assign(new Error('thread/read senza stato thread riconoscibile'), { code: 'EPROTO' });
143
+ done(null, value);
144
+ } catch (e) {
145
+ done(e);
146
+ }
147
+ });
148
+
149
+ ws.on('message', (data) => {
150
+ let msg;
151
+ try { msg = JSON.parse(String(data)); } catch (_) { return; }
152
+ if (msg && msg.id != null && ws.pending && ws.pending.has(msg.id)) {
153
+ const waiter = ws.pending.get(msg.id);
154
+ ws.pending.delete(msg.id);
155
+ if (msg.error) waiter.rej(Object.assign(new Error(msg.error.message || 'jsonrpc error'), { code: 'ERPC', detail: msg.error }));
156
+ else waiter.res(msg.result);
157
+ }
158
+ });
159
+
160
+ ws.on('error', (e) => done(Object.assign(new Error(`control socket: ${e.message}`), { code: 'ESOCKET' })));
161
+ ws.on('close', () => {
162
+ if (!settled) done(Object.assign(new Error('control socket chiuso prima della risposta'), { code: 'ESOCKET' }));
163
+ });
164
+ });
165
+ }
73
166
 
74
167
  // —— Prompt per-cella (rev4 LC2, nome fisso confermato il 2026-08-15) ——
75
168
  // Collocazione: filesRoot/<tmuxSession>/LIVE_PROMPT.md — la sessione tmux
@@ -167,6 +260,7 @@ function identityHeader(cellId, tmuxSession) {
167
260
  // e la finestra resta minima.
168
261
  function startThreadOnControlSocket({
169
262
  socketPath, cwd, developerInstructions, timeoutMs,
263
+ declaredSession,
170
264
  WebSocket = defaultWebSocket(), now = () => Date.now(), log = () => {},
171
265
  }) {
172
266
  return new Promise((resolve, reject) => {
@@ -244,7 +338,17 @@ function startThreadOnControlSocket({
244
338
  if (settled) { chiudi(); return; }
245
339
  try {
246
340
  await request('initialize', {
247
- clientInfo: { name: CLIENT_NAME, title: 'NexusCrew Live Bridge', version: bridgeVersion() },
341
+ clientInfo: {
342
+ name: CLIENT_NAME,
343
+ title: 'NexusCrew Live Bridge',
344
+ version: bridgeVersion(),
345
+ // Identita della connessione: la sessione gia' risolta della cella
346
+ // designata (mai env grezzi, mai guess — roster MC3.4). Se assente
347
+ // il campo SI OMETTE (Option None lato protocollo): il resolver a
348
+ // valle classifica MISSING nominando la causa; sanitizzare qui la
349
+ // duplicherebbe senza guadagno.
350
+ ...(declaredSession ? { nexuscrewSession: declaredSession } : {}),
351
+ },
248
352
  capabilities: { experimentalApi: true },
249
353
  });
250
354
  send({ jsonrpc: '2.0', method: 'initialized' }); // notifica, senza params
@@ -309,6 +413,11 @@ function createLiveBridge({
309
413
  log = () => {},
310
414
  }) {
311
415
  const root = filesRoot || cfg.filesRoot || path.join(os.homedir(), 'NexusFiles');
416
+ const threadIdsByCell = new Map();
417
+ const threadStatusCache = new Map();
418
+ const threadStatusInFlight = new Map();
419
+ const threadStatusCacheMs = Number.isFinite(cfg.liveThreadStatusCacheMs)
420
+ ? Math.max(0, cfg.liveThreadStatusCacheMs) : THREAD_STATUS_CACHE_MS;
312
421
 
313
422
  const none = (reason, extra) => ({ mode: 'none', reason, ...(extra || {}), at: now() });
314
423
 
@@ -345,6 +454,32 @@ function createLiveBridge({
345
454
  return cells.find((c) => c && c.cell === cellId) || null;
346
455
  }
347
456
 
457
+ async function threadStatus(cellId) {
458
+ const threadId = threadIdsByCell.get(cellId);
459
+ if (!threadId) return 'unknown';
460
+
461
+ const current = now();
462
+ const cached = threadStatusCache.get(cellId);
463
+ if (cached && cached.expiresAt > current) return cached.value;
464
+ if (cached) threadStatusCache.delete(cellId);
465
+ if (threadStatusInFlight.has(cellId)) return threadStatusInFlight.get(cellId);
466
+
467
+ const query = queryThreadStatusOnControlSocket({
468
+ socketPath: cfg.liveBridgeSocketPath,
469
+ threadId,
470
+ timeoutMs: cfg.liveBridgeTimeoutMs,
471
+ WebSocket,
472
+ }).catch(() => 'unknown').then((value) => {
473
+ // Anche unknown e' la risposta della query corrente: non riusiamo un
474
+ // valore buono oltre la sua finestra, e il prossimo tick potra' misurare
475
+ // di nuovo il nodo.
476
+ threadStatusCache.set(cellId, { value, expiresAt: now() + threadStatusCacheMs });
477
+ return value;
478
+ }).finally(() => { threadStatusInFlight.delete(cellId); });
479
+ threadStatusInFlight.set(cellId, query);
480
+ return query;
481
+ }
482
+
348
483
  // Risolve il puntamento per l'avvio di una Live. Sempre una risposta utile:
349
484
  // i `none` sono modi legittimi di non puntare, e il reason distingue le
350
485
  // cause (designazione assente, cella non idonea, fallback su fallimento).
@@ -411,6 +546,10 @@ function createLiveBridge({
411
546
  socketPath: cfg.liveBridgeSocketPath,
412
547
  cwd: cell.cwd,
413
548
  developerInstructions,
549
+ // Stessa fonte dell'intestazione e del prompt per-cella (roster:
550
+ // MC3.4). Dichiarata per connessione: batte qualunque ambiente
551
+ // ereditato, anche quando e' popolato ma stantio (B1-bis).
552
+ declaredSession: cell.tmuxSession,
414
553
  timeoutMs: cfg.liveBridgeTimeoutMs,
415
554
  WebSocket,
416
555
  log,
@@ -429,6 +568,8 @@ function createLiveBridge({
429
568
  socketPath: cfg.liveBridgeSocketPath,
430
569
  at: now(),
431
570
  };
571
+ threadIdsByCell.set(snap.hostCell, started.threadId);
572
+ threadStatusCache.delete(snap.hostCell);
432
573
  // LC1.4: il puntamento è visibile lato nostro — log con cella, thread e
433
574
  // prompt applicato. È il "dirottamento dichiarato" del contratto. Il
434
575
  // campo SOSTITUISCE le developer instructions della config (MC2, la
@@ -438,7 +579,11 @@ function createLiveBridge({
438
579
  return out;
439
580
  }
440
581
 
441
- return { resolveForLive, readCellPrompt: (tmuxSession) => { const { text, ...rest } = readCellPrompt(root, tmuxSession); return rest; } };
582
+ return {
583
+ resolveForLive,
584
+ threadStatus,
585
+ readCellPrompt: (tmuxSession) => { const { text, ...rest } = readCellPrompt(root, tmuxSession); return rest; },
586
+ };
442
587
  }
443
588
 
444
589
  let cachedVersion = null;
@@ -452,4 +597,7 @@ function bridgeVersion() {
452
597
  return cachedVersion;
453
598
  }
454
599
 
455
- module.exports = { createLiveBridge, readCellPrompt, startThreadOnControlSocket, CLIENT_NAME };
600
+ module.exports = {
601
+ createLiveBridge, readCellPrompt, startThreadOnControlSocket,
602
+ queryThreadStatusOnControlSocket, CLIENT_NAME,
603
+ };
@@ -75,7 +75,9 @@ function eligibleOf(fleet, cell, hostCell) {
75
75
  function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Date.now(), bridge = null }) {
76
76
  const r = express.Router();
77
77
 
78
- // GET /api/live-host — { hostCell, revision, eligible, host: {lease}, at }.
78
+ // GET /api/live-host — { hostCell, revision, eligible, threadStatus,
79
+ // host: {lease}, at }. threadStatus misura il runtime del thread ponte:
80
+ // absent/present/active/unknown; non dichiara la presenza del client Live.
79
81
  // hostCell e revision vengono dallo store (preservato); eligible e' la verita'
80
82
  // COMPOSTA roster+lease (si veda hostLeaseState sopra); host.lease espone lo
81
83
  // stato del lease della cella designata, distinto, perche' chi legge distingue.
@@ -84,14 +86,20 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
84
86
  const snap = store.snapshot();
85
87
  let eligible = false;
86
88
  let lease = null;
89
+ let threadStatus = 'absent';
87
90
  if (snap.hostCell != null) {
88
91
  const fleet = await fleetP.catch(() => null);
89
92
  const cells = await localCells(fleetP).catch(() => []);
90
93
  const cell = Array.isArray(cells) ? cells.find((c) => c && c.cell === snap.hostCell) : null;
91
94
  lease = hostLeaseState(fleet, snap.hostCell);
92
95
  eligible = eligibleOf(fleet, cell, snap.hostCell);
96
+ if (bridge && typeof bridge.threadStatus === 'function') {
97
+ threadStatus = await Promise.resolve(bridge.threadStatus(snap.hostCell)).catch(() => 'unknown');
98
+ } else {
99
+ threadStatus = 'unknown';
100
+ }
93
101
  }
94
- res.json({ hostCell: snap.hostCell, revision: snap.revision, eligible, host: { lease }, at: now() });
102
+ res.json({ hostCell: snap.hostCell, revision: snap.revision, eligible, threadStatus, host: { lease }, at: now() });
95
103
  } catch (e) {
96
104
  res.status(500).json({ error: String(e && e.message || e) });
97
105
  }
package/lib/mcp/server.js CHANGED
@@ -202,13 +202,35 @@ function createMcpServer(opts = {}) {
202
202
  const cfg = opts.config || loadConfig();
203
203
  const baseUrl = `http://127.0.0.1:${cfg.port}`;
204
204
 
205
- // Identita' risolta una volta e cacheata (la sessione tmux non cambia a runtime).
206
- // Una sola risoluzione condivisa: `identity()` per la diagnostica completa
207
- // (source/code/presence), `session()` estrae solo il nome per gli handler
208
- // storici (compatibilita'). Nessuna API/token coinvolta qui.
209
- let identityP = null;
205
+ // Identita' risolta una volta e cacheata ma solo se riesce.
206
+ //
207
+ // Perche' il successo e il fallimento hanno vita diversa: una sessione
208
+ // risolta non cambia per la vita del processo (cache storica, invariata);
209
+ // un FALLIMENTO invece non deve restare bloccato per sempre. Il caso reale:
210
+ // questo server MCP parte in un daemon avviato da systemd PRIMA che il
211
+ // server tmux sia raggiungibile, `display-message` fallisce, e con la cache
212
+ // a vita l'identita' restava assente anche dopo che tmux era su. Un
213
+ // fallimento viene quindi ri-tentato, con anti-hammering: al piu' una
214
+ // risoluzione ogni IDENTITY_RETRY_MS finche' non riesce (un tmux rotto non
215
+ // puo' trasformare ogni tool call in una execFile da 3 s).
216
+ // `identity()` per la diagnostica completa (source/code/presence),
217
+ // `session()` estrae solo il nome per gli handler storici (compatibilita').
218
+ // Nessuna API/token coinvolta qui.
219
+ // Iniettabile nei test per non attendere 30 s reali (opts.identityRetryMs).
220
+ const IDENTITY_RETRY_MS = opts.identityRetryMs ?? 30_000;
221
+ let identityP = null; // promise condivisa in corso/cacheata
222
+ let identityOk = false; // solo un esito OK resta cacheato a vita
223
+ let identityAttemptAt = 0; // istante dell'ultimo tentativo (anti-spam)
210
224
  const identity = () => {
211
- if (!identityP) identityP = resolveIdentity({ env, tmuxBin: cfg.tmuxBin || 'tmux', execFileImpl });
225
+ if (identityOk) return identityP;
226
+ const now = Date.now();
227
+ if (identityP && now - identityAttemptAt < IDENTITY_RETRY_MS) return identityP;
228
+ identityAttemptAt = now;
229
+ identityP = resolveIdentity({ env, tmuxBin: cfg.tmuxBin || 'tmux', execFileImpl })
230
+ .then((i) => {
231
+ identityOk = i.code === IDENTITY_CODE.OK;
232
+ return i;
233
+ });
212
234
  return identityP;
213
235
  };
214
236
  const session = () => identity().then((i) => i.session);
package/lib/mcp/tools.js CHANGED
@@ -100,15 +100,33 @@ function vlLookupFailure(directory, { instanceId, nodeId = null }) {
100
100
  + ' — copia l\'id esatto da nc_vl_nodes invece di ribatterlo';
101
101
  }
102
102
 
103
+ // Perche' il messaggio nomina la causa invece della sola remediation.
104
+ //
105
+ // `resolveIdentity` distingue quattro modi di non identificarsi (STALE_PANE,
106
+ // SESSION_MISMATCH, INVALID, MISSING) e `nc_identity` li mostra — ma il tool
107
+ // che si blocca e' quello che il modello sta chiamando, e il suo errore era
108
+ // ridotto a MISSING con la remediation generica. Chi lo leggeva andava a
109
+ // allowlistare variabili che erano gia' allowlistate: il pane era morto, o le
110
+ // due fonti discordavano. Ogni causa porta un'azione diversa: il messaggio del
111
+ // tool che si blocca deve dire quale e' la sua.
112
+ const IDENTITY_CAUSE = Object.freeze({
113
+ [IDENTITY_CODE.STALE_PANE]: 'il pane tmux del chiamante non risponde (morto o non verificabile)',
114
+ [IDENTITY_CODE.SESSION_MISMATCH]: 'tmux e NEXUSCREW_MCP_SESSION dichiarano due sessioni diverse: identita ambigua, nessuna delle due viene attribuita',
115
+ [IDENTITY_CODE.INVALID]: 'una fonte di identita e presente ma invalida',
116
+ [IDENTITY_CODE.MISSING]: 'nessuna fonte di identita: serve $TMUX (dentro tmux) o NEXUSCREW_MCP_SESSION',
117
+ });
118
+
103
119
  function requireSession(session, tool, code = IDENTITY_CODE.MISSING) {
104
120
  if (session) return session;
105
- const stableCode = code === IDENTITY_CODE.INVALID ? IDENTITY_CODE.INVALID : IDENTITY_CODE.MISSING;
106
- // Errore umano per il modello: messaggio chiaro + codice stabile fra parentesi
107
- // quadre. isError=true e' impostato dal server (toolsCall); qui si propaga
108
- // solo il testo. La regex storica /NEXUSCREW_MCP_SESSION/ resta soddisfatta.
121
+ const stableCode = Object.values(IDENTITY_CODE).includes(code) ? code : IDENTITY_CODE.MISSING;
122
+ const cause = IDENTITY_CAUSE[stableCode];
123
+ // Errore umano per il modello: causa nominata + remediation + codice stabile
124
+ // fra parentesi quadre. isError=true e' impostato dal server (toolsCall);
125
+ // qui si propaga solo il testo. La regex storica /NEXUSCREW_MCP_SESSION/
126
+ // resta soddisfatta (presente nella remediation e nel caso MISSING).
109
127
  throw new Error(
110
- `${tool}: sessione tmux non identificata — serve $TMUX (dentro tmux) o `
111
- + `NEXUSCREW_MCP_SESSION [${stableCode}]`,
128
+ `${tool}: sessione tmux non identificata — ${cause}. `
129
+ + `${IDENTITY_REMEDIATION} [${stableCode}]`,
112
130
  );
113
131
  }
114
132
 
@@ -20,9 +20,44 @@ const nodesTunnel = require('./tunnel.js');
20
20
  const { probeHealth } = require('../proxy/federation.js');
21
21
 
22
22
  const TTL_MS = 5000;
23
+ const FAILURE_THRESHOLD = 3;
23
24
  const cache = new Map(); // name -> { health, at }
25
+ const probeFailures = new Map(); // cache key -> consecutive transport probe failures
24
26
 
25
- function clearHealthCache() { cache.clear(); }
27
+ function clearHealthCache() { cache.clear(); probeFailures.clear(); }
28
+
29
+ // A single late probe is not evidence that a still-up tunnel has died. Keep
30
+ // the last authoritative health while the threshold is pending; once reached,
31
+ // expose the real down result. Recovery is immediate on the first good probe.
32
+ function applyProbeHysteresis(key, probed, previous, now, threshold) {
33
+ if (probed.transport !== 'down') {
34
+ probeFailures.delete(key);
35
+ return probed;
36
+ }
37
+ const failures = (probeFailures.get(key) || 0) + 1;
38
+ probeFailures.set(key, failures);
39
+ if (failures >= threshold) return { ...probed, consecutiveFailures: failures };
40
+ if (previous && previous.transport === 'up') {
41
+ return {
42
+ ...previous,
43
+ at: now,
44
+ probePending: true,
45
+ consecutiveFailures: failures,
46
+ detail: `health probe transitorio (${failures}/${threshold})`,
47
+ };
48
+ }
49
+ return {
50
+ ...probed,
51
+ transport: 'unknown',
52
+ auth: 'unknown',
53
+ reachability: 'unknown',
54
+ status: 'unknown',
55
+ at: now,
56
+ probePending: true,
57
+ consecutiveFailures: failures,
58
+ detail: `health probe transitorio (${failures}/${threshold})`,
59
+ };
60
+ }
26
61
 
27
62
  // Compatibilita' tunnel per il frontend attuale (che legge tunnel.status): deriva
28
63
  // uno {status, managed} retro-compatibile dal model health, senza reintrodurre il
@@ -39,11 +74,14 @@ function tunnelFromHealth(h) {
39
74
  return { status: 'unknown', managed: false }; // inbound / unknown
40
75
  }
41
76
 
42
- async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
77
+ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false, failureThreshold = FAILURE_THRESHOLD }) {
43
78
  if (!node || typeof node !== 'object') return null;
44
79
  const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}\0${node.shared === true}\0${node.rolesKnown === true}\0${node.roles?.node === true}`;
45
- const cached = !force && cache.get(cacheKey);
46
- if (cached && (now - cached.at) < TTL_MS) return cached.health;
80
+ const cached = cache.get(cacheKey);
81
+ if (!force && cached && (now - cached.at) < TTL_MS) return cached.health;
82
+ const previous = cached?.health || null;
83
+ const threshold = Number.isSafeInteger(failureThreshold) && failureThreshold > 0
84
+ ? failureThreshold : FAILURE_THRESHOLD;
47
85
 
48
86
  let health;
49
87
  if (node.direction === 'inbound') {
@@ -91,7 +129,7 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
91
129
  detail: node.rolesKnown === true ? 'client peer offline (expected)' : 'inbound peer offline',
92
130
  };
93
131
  } else {
94
- health = { ...probed, managed: false };
132
+ health = { ...applyProbeHysteresis(cacheKey, probed, previous, now, threshold), managed: false };
95
133
  }
96
134
  }
97
135
  } else {
@@ -115,16 +153,16 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
115
153
  const probed = await probeHealth({
116
154
  port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
117
155
  });
118
- health = { ...probed, transportEngine: ts.transport || 'ssh', managed: true };
156
+ health = { ...applyProbeHysteresis(cacheKey, probed, previous, now, threshold), transportEngine: ts.transport || 'ssh', managed: true };
119
157
  }
120
158
  }
121
159
  cache.set(cacheKey, { health, at: now });
122
160
  return health;
123
161
  }
124
162
 
125
- async function nodesHealth({ nodes, home, fetchImpl, now = Date.now() }) {
163
+ async function nodesHealth({ nodes, home, fetchImpl, now = Date.now(), failureThreshold = FAILURE_THRESHOLD }) {
126
164
  if (!Array.isArray(nodes) || nodes.length === 0) return [];
127
- return Promise.all(nodes.map((node) => nodeHealth({ node, home, fetchImpl, now })));
165
+ return Promise.all(nodes.map((node) => nodeHealth({ node, home, fetchImpl, now, failureThreshold })));
128
166
  }
129
167
 
130
- module.exports = { nodeHealth, nodesHealth, tunnelFromHealth, clearHealthCache, TTL_MS };
168
+ module.exports = { nodeHealth, nodesHealth, tunnelFromHealth, clearHealthCache, TTL_MS, FAILURE_THRESHOLD };
package/lib/server.js CHANGED
@@ -14,7 +14,7 @@ const { createSession, killSession, isProtectedSession } = require('./tmux/lifec
14
14
  const { createPreviewSampler } = require('./tmux/preview.js');
15
15
  const { requireSharedTmuxProtection } = require('./tmux/shared-server.js');
16
16
  const { openAttach } = require('./pty/attach.js');
17
- const { bindWs } = require('./ws/bridge.js');
17
+ const { bindWs, createPtyGraceStore } = require('./ws/bridge.js');
18
18
  const { loadOrCreateToken, verify } = require('./auth/token.js');
19
19
  const { requireToken, bearerFrom } = require('./auth/middleware.js');
20
20
  const { filesRoutes } = require('./files/routes.js');
@@ -105,6 +105,11 @@ function createServer(opts = {}) {
105
105
  // wss viene creato piu' sotto; closeSessions lo raggiunge a request-time (mai durante
106
106
  // createServer) per chiudere le sessioni WS attive sulla rotazione token (§4b(3)).
107
107
  let wss = null;
108
+ const ptyGrace = createPtyGraceStore({
109
+ graceMs: cfg.ptyGraceMs,
110
+ maxSessions: cfg.ptyGraceMaxSessions,
111
+ maxMemoryBytes: cfg.ptyGraceMaxMemoryBytes,
112
+ });
108
113
  const closeSessions = () => {
109
114
  if (wss) {
110
115
  for (const ws of wss.clients) { try { ws.close(4001, 'token rotated'); } catch (_) { /* best-effort */ } }
@@ -983,6 +988,7 @@ function createServer(opts = {}) {
983
988
  const view = nodesStore.redactStore(st);
984
989
  const healths = await nodesHealth.nodesHealth({
985
990
  nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
991
+ failureThreshold: cfg.nodeHealthFailureThreshold,
986
992
  });
987
993
  const nodes = view.nodes.map((n, i) => {
988
994
  const h = healths[i] || null;
@@ -1006,6 +1012,7 @@ function createServer(opts = {}) {
1006
1012
  const direct = nodesStore.redactStore(st).nodes;
1007
1013
  const healths = await nodesHealth.nodesHealth({
1008
1014
  nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
1015
+ failureThreshold: cfg.nodeHealthFailureThreshold,
1009
1016
  });
1010
1017
  const extras = new Map(direct.map((node, index) => {
1011
1018
  const health = healths[index] || null;
@@ -1115,7 +1122,7 @@ function createServer(opts = {}) {
1115
1122
  });
1116
1123
  server.on('close', () => {
1117
1124
  diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
1118
- watcher.close(); previews.close(); eventsHub.closeAll(); updater.close();
1125
+ watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
1119
1126
  for (const timer of reverseWatchers.values()) clearInterval(timer);
1120
1127
  reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
1121
1128
  // Il pannello non sopravvive al control plane: senza requireToken sopra,
@@ -1225,6 +1232,7 @@ function createServer(opts = {}) {
1225
1232
  runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
1226
1233
  countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
1227
1234
  defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
1235
+ ptyGrace,
1228
1236
  onAttach: (sess) => attachedWs.set(ws, sess),
1229
1237
  });
1230
1238
  ws.on('close', () => attachedWs.delete(ws));