@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.
- package/frontend/dist/assets/index-CprIQlC5.css +32 -0
- package/frontend/dist/assets/index-ToJ5arRI.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/config.js +12 -0
- package/lib/live-host/bridge.js +156 -8
- package/lib/live-host/routes.js +10 -2
- package/lib/mcp/server.js +28 -6
- package/lib/mcp/tools.js +24 -6
- package/lib/nodes/health.js +47 -9
- package/lib/server.js +10 -2
- package/lib/ws/bridge.js +190 -24
- package/package.json +1 -1
- package/frontend/dist/assets/index-BBCrZoxR.js +0 -93
- package/frontend/dist/assets/index-keXh4CAm.css +0 -32
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-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.
|
|
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
|
|
package/lib/live-host/bridge.js
CHANGED
|
@@ -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
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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}
|
|
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: {
|
|
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 {
|
|
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 = {
|
|
600
|
+
module.exports = {
|
|
601
|
+
createLiveBridge, readCellPrompt, startThreadOnControlSocket,
|
|
602
|
+
queryThreadStatusOnControlSocket, CLIENT_NAME,
|
|
603
|
+
};
|
package/lib/live-host/routes.js
CHANGED
|
@@ -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,
|
|
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
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
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 (
|
|
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 =
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
//
|
|
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 —
|
|
111
|
-
+
|
|
128
|
+
`${tool}: sessione tmux non identificata — ${cause}. `
|
|
129
|
+
+ `${IDENTITY_REMEDIATION} [${stableCode}]`,
|
|
112
130
|
);
|
|
113
131
|
}
|
|
114
132
|
|
package/lib/nodes/health.js
CHANGED
|
@@ -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 =
|
|
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));
|