@mmmbuto/nexuscrew 0.8.9 → 0.8.11
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/README.md +48 -20
- package/frontend/dist/assets/index-DoUIJ4GM.js +91 -0
- package/frontend/dist/assets/index-DrrRAIg6.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/auth/token.js +1 -1
- package/lib/cli/commands.js +287 -147
- package/lib/cli/doctor.js +37 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/cli/service.js +52 -0
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +12 -1
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +22 -14
- package/lib/nodes/peering.js +72 -10
- package/lib/nodes/store.js +21 -18
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +68 -6
- package/lib/server.js +36 -31
- package/lib/settings/routes.js +202 -95
- package/lib/update/runner.js +4 -1
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-9Drd8g0k.css +0 -32
- package/frontend/dist/assets/index-DTmT7yhV.js +0 -91
package/lib/cli/doctor.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// nexuscrew doctor: auto-diagnosi (design §3, §7). [A2]
|
|
3
3
|
// Struttura ESTENSIBILE: una lista di check-fn, ognuna ritorna
|
|
4
|
-
// { name, ok, warn?, detail? }.
|
|
5
|
-
//
|
|
4
|
+
// { name, ok, warn?, detail? }. SSH è un requisito locale; la policy del
|
|
5
|
+
// server remoto si prova soltanto tentando il forwarding reale.
|
|
6
6
|
// Exit code: 0 se tutti ok (i warn non falliscono), 1 se almeno un check è FAIL.
|
|
7
7
|
// Nessun segreto nell'output (mai il token, solo il path + i permessi).
|
|
8
8
|
const fs = require('node:fs');
|
|
@@ -84,25 +84,45 @@ function checkBoot(platform, home, execImpl) {
|
|
|
84
84
|
return { name: 'boot (launchd RunAtLoad)', ok: true, warn: !ok, detail: ok ? 'plist installato' : 'plist non installato' };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function checkTmuxSurvival(platform, execImpl) {
|
|
88
|
+
if (platform !== 'linux') {
|
|
89
|
+
return { name: 'tmux survival on service restart', ok: true, detail: `${platform}: systemd cgroup non applicabile` };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const value = String(execImpl('systemctl', [
|
|
93
|
+
'--user', 'show', 'nexuscrew.service', '--property=KillMode', '--value',
|
|
94
|
+
], { encoding: 'utf8' }) || '').trim();
|
|
95
|
+
const ok = value === 'process';
|
|
96
|
+
return {
|
|
97
|
+
name: 'tmux survival on service restart', ok,
|
|
98
|
+
detail: ok ? 'KillMode=process' : `KillMode=${value || 'sconosciuto'} (restart NexusCrew puo terminare tmux)`,
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { name: 'tmux survival on service restart', ok: false, detail: `KillMode non verificabile: ${error.message || error}` };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
// ssh client presente su PATH: prerequisito dei tunnel multi-node (design §4).
|
|
88
106
|
function checkSshClient(existsImpl) {
|
|
89
107
|
return existsImpl('ssh')
|
|
90
|
-
? { name: '
|
|
91
|
-
: { name: '
|
|
108
|
+
? { name: 'OpenSSH transport', ok: true, detail: 'ssh presente · USATO dal supervisor NexusCrew' }
|
|
109
|
+
: { name: 'OpenSSH transport', ok: false, detail: 'ssh non trovato su PATH; autossh da solo non funziona senza OpenSSH' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function checkAutossh(existsImpl) {
|
|
113
|
+
return existsImpl('autossh')
|
|
114
|
+
? { name: 'autossh', ok: true, detail: 'presente · NON usato (retry gia gestito dal supervisor NexusCrew)' }
|
|
115
|
+
: { name: 'autossh', ok: true, warn: true, detail: 'assente · opzionale, non necessario con SSH supervisionato' };
|
|
92
116
|
}
|
|
93
117
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
118
|
+
// Versione locale informativa. `permitlisten` e' una policy del server sshd e
|
|
119
|
+
// NON puo' essere certificata guardando `ssh -V` sul client: Share la verifica
|
|
120
|
+
// con un vero -R + health autenticato.
|
|
97
121
|
function checkSshPermitlisten(sshVersionImpl) {
|
|
98
122
|
const tun = require('../nodes/tunnel.js');
|
|
99
123
|
const v = tun.readSshVersion(sshVersionImpl);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, warn: true, detail: 'versione ssh non determinabile — verifica prima di abilitare il ruolo node' };
|
|
103
|
-
}
|
|
104
|
-
if (supp) return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, detail: v.raw };
|
|
105
|
-
return { name: 'OpenSSH permitlisten (>=7.8)', ok: false, detail: `OpenSSH ${v.major}.${v.minor} < 7.8 — permitlisten non supportato (ruolo node non abilitabile)` };
|
|
124
|
+
if (!v) return { name: 'OpenSSH version', ok: true, warn: true, detail: 'versione non determinabile; Share verra verificato a runtime sul server' };
|
|
125
|
+
return { name: 'OpenSSH version', ok: true, detail: `${v.raw} · policy reverse verificata a runtime` };
|
|
106
126
|
}
|
|
107
127
|
|
|
108
128
|
function checkTokenPerms(tokenPath) {
|
|
@@ -139,8 +159,10 @@ function doctor(opts = {}) {
|
|
|
139
159
|
checkPty(ptyLoad),
|
|
140
160
|
checkService(platform, home, execImpl, uidVal, opts.installPath),
|
|
141
161
|
checkBoot(platform, home, execImpl),
|
|
162
|
+
checkTmuxSurvival(platform, execImpl),
|
|
142
163
|
checkTokenPerms(tokenPath),
|
|
143
164
|
checkSshClient(existsImpl),
|
|
165
|
+
checkAutossh(existsImpl),
|
|
144
166
|
checkSshPermitlisten(opts.sshVersion),
|
|
145
167
|
];
|
|
146
168
|
|
|
@@ -156,5 +178,6 @@ function doctor(opts = {}) {
|
|
|
156
178
|
module.exports = {
|
|
157
179
|
doctor, nodeMajor,
|
|
158
180
|
checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
|
|
159
|
-
|
|
181
|
+
checkTmuxSurvival,
|
|
182
|
+
checkSshClient, checkAutossh, checkSshPermitlisten,
|
|
160
183
|
};
|
package/lib/cli/init.js
CHANGED
|
@@ -115,13 +115,13 @@ function runInit(opts = {}) {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
// Fleet app defaults: soltanto i
|
|
118
|
+
// Fleet app defaults: soltanto i quattro client nativi. Provider cloud/Z.AI sono
|
|
119
119
|
// disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
|
|
120
120
|
const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
|
|
121
121
|
if (!dryRun && !fs.existsSync(fleetDefsPath)) {
|
|
122
122
|
try {
|
|
123
123
|
writeFleet(fleetDefsPath, defaultDefinitions());
|
|
124
|
-
actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native)`);
|
|
124
|
+
actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native, pi.native)`);
|
|
125
125
|
} catch (e) {
|
|
126
126
|
actions.push(`WARN: fleet defaults non creati: ${e.message}`);
|
|
127
127
|
}
|
package/lib/cli/pidfile.js
CHANGED
|
@@ -19,9 +19,10 @@ function readPidfile(p) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// Exclusive create (wx): fallisce se il pidfile esiste già (no overwrite silenzioso).
|
|
22
|
-
function writePidfile(p, pid, cmd) {
|
|
22
|
+
function writePidfile(p, pid, cmd, extra = {}) {
|
|
23
23
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
24
|
-
const
|
|
24
|
+
const safeExtra = extra && typeof extra === 'object' && !Array.isArray(extra) ? extra : {};
|
|
25
|
+
const meta = JSON.stringify({ pid, cmd: cmd || '', startTs: Date.now(), ...safeExtra });
|
|
25
26
|
fs.writeFileSync(p, meta + '\n', { flag: 'wx', mode: 0o600 });
|
|
26
27
|
}
|
|
27
28
|
|
package/lib/cli/service.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
const path = require('node:path');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
7
8
|
const { execFileSync } = require('node:child_process');
|
|
8
9
|
const { detectPlatform, uid } = require('./platform.js');
|
|
9
10
|
|
|
@@ -69,6 +70,9 @@ After=network-online.target
|
|
|
69
70
|
|
|
70
71
|
[Service]
|
|
71
72
|
Type=simple
|
|
73
|
+
# The HTTP service may be the process that creates the shared tmux server.
|
|
74
|
+
# Restart only NexusCrew itself; tmux sessions are independent user workloads.
|
|
75
|
+
KillMode=process
|
|
72
76
|
WorkingDirectory=${repo}
|
|
73
77
|
Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
|
|
74
78
|
ExecStart=${node} ${repoBin} serve
|
|
@@ -153,6 +157,53 @@ function fileMode(platform) {
|
|
|
153
157
|
return 0o644; // systemd unit + launchd plist
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
const LINUX_TMUX_SURVIVAL_DROPIN = `[Service]\n# NexusCrew owns the HTTP process, not the shared tmux server it may start.\nKillMode=process\n`;
|
|
161
|
+
|
|
162
|
+
function linuxTmuxSurvivalPath(home) {
|
|
163
|
+
return path.join(home, '.config', 'systemd', 'user', 'nexuscrew.service.d', '10-tmux-survival.conf');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Upgrade existing Linux installations before stop/restart. Merely updating
|
|
167
|
+
// the npm package does not rewrite an already-installed unit, so the new CLI
|
|
168
|
+
// installs a narrow drop-in and reloads systemd before any destructive service
|
|
169
|
+
// lifecycle operation. The target is atomic and never follows a symlink.
|
|
170
|
+
function ensureLinuxTmuxSurvival(opts = {}) {
|
|
171
|
+
const home = opts.home || os.homedir();
|
|
172
|
+
const target = opts.dropInPath || linuxTmuxSurvivalPath(home);
|
|
173
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
174
|
+
let current = '';
|
|
175
|
+
try {
|
|
176
|
+
const st = fs.lstatSync(target);
|
|
177
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error(`refusing unsafe systemd drop-in: ${target}`);
|
|
178
|
+
current = fs.readFileSync(target, 'utf8');
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error.code !== 'ENOENT') throw error;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let written = false;
|
|
184
|
+
if (current !== LINUX_TMUX_SURVIVAL_DROPIN) {
|
|
185
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
186
|
+
const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(6).toString('hex')}`;
|
|
187
|
+
try {
|
|
188
|
+
fs.writeFileSync(tmp, LINUX_TMUX_SURVIVAL_DROPIN, { mode: 0o644, flag: 'wx' });
|
|
189
|
+
fs.chmodSync(tmp, 0o644);
|
|
190
|
+
fs.renameSync(tmp, target);
|
|
191
|
+
written = true;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
execImpl('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
198
|
+
const effective = String(execImpl('systemctl', [
|
|
199
|
+
'--user', 'show', 'nexuscrew.service', '--property=KillMode', '--value',
|
|
200
|
+
], { encoding: 'utf8' }) || '').trim();
|
|
201
|
+
if (effective !== 'process') {
|
|
202
|
+
throw new Error(`unsafe effective systemd KillMode=${effective || 'unknown'}; refusing NexusCrew lifecycle operation`);
|
|
203
|
+
}
|
|
204
|
+
return { target, written, killMode: effective };
|
|
205
|
+
}
|
|
206
|
+
|
|
156
207
|
// Install no-symlink + atomic rename. [M3]
|
|
157
208
|
// - lstat target: reject symlink (no follow)
|
|
158
209
|
// - write temp file nella stessa dir -> chmod mode -> atomic rename
|
|
@@ -230,6 +281,7 @@ function installCommands(platform, target, ctx) {
|
|
|
230
281
|
module.exports = {
|
|
231
282
|
generateService, generateLinux, generateMac, generateTermux,
|
|
232
283
|
installService, installPath, installCommands, fileMode,
|
|
284
|
+
ensureLinuxTmuxSurvival, linuxTmuxSurvivalPath, LINUX_TMUX_SURVIVAL_DROPIN,
|
|
233
285
|
escapeSystemdPath, escapeSystemdExec, escapeXml, shellQuote,
|
|
234
286
|
assertSystemdSafe, SYSTEMD_FORBIDDEN,
|
|
235
287
|
};
|
package/lib/decks/store.js
CHANGED
|
@@ -9,6 +9,7 @@ const MAX_DECKS = 24;
|
|
|
9
9
|
const MAX_TILES = 9;
|
|
10
10
|
const NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
11
11
|
const NODE_RE = /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32}){0,3}$/;
|
|
12
|
+
const OWNER_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
12
13
|
|
|
13
14
|
function validNodeRoute(node) {
|
|
14
15
|
if (!NODE_RE.test(node)) return false;
|
|
@@ -48,15 +49,17 @@ function parseLayout(raw) {
|
|
|
48
49
|
for (const t of c.tiles) {
|
|
49
50
|
if (!t || typeof t !== 'object' || Array.isArray(t) || !validText(t.session, 128)) return null;
|
|
50
51
|
if (t.node !== undefined && (typeof t.node !== 'string' || !validNodeRoute(t.node))) return null;
|
|
52
|
+
if (t.ownerId !== undefined && (typeof t.ownerId !== 'string' || !OWNER_ID_RE.test(t.ownerId))) return null;
|
|
51
53
|
const height = Number(t.height);
|
|
52
54
|
const fontSize = Number(t.fontSize);
|
|
53
55
|
if (!Number.isFinite(height) || height < 0.2 || height > 100) return null;
|
|
54
56
|
if (!Number.isFinite(fontSize) || fontSize < 9 || fontSize > 24) return null;
|
|
55
|
-
const key = t.node ? `${t.node}:${t.session}` : t.session;
|
|
57
|
+
const key = t.ownerId ? `${t.ownerId}:${t.session}` : t.node ? `${t.node}:${t.session}` : t.session;
|
|
56
58
|
if (seen.has(key) || ++count > MAX_TILES) return null;
|
|
57
59
|
seen.add(key);
|
|
58
60
|
const tile = { session: t.session, height, fontSize };
|
|
59
61
|
if (t.node) tile.node = t.node;
|
|
62
|
+
if (t.ownerId) tile.ownerId = t.ownerId;
|
|
60
63
|
tiles.push(tile);
|
|
61
64
|
}
|
|
62
65
|
columns.push({ width, tiles });
|
|
@@ -118,6 +121,6 @@ function loadOrCreate(p) {
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
module.exports = {
|
|
121
|
-
SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE,
|
|
124
|
+
SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE, OWNER_ID_RE,
|
|
122
125
|
defaultDecksPath, emptyStore, parseLayout, parseStore, loadStore, loadOrCreate, atomicWrite,
|
|
123
126
|
};
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -371,6 +371,17 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
371
371
|
throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
// `tmux new-session -d` can return 0 even when the launched CLI exits a
|
|
375
|
+
// moment later (missing login, bad model, incompatible provider). Without
|
|
376
|
+
// this readiness gate the PWA reported success and then showed nothing.
|
|
377
|
+
// Always verify liveness, including cells without a system prompt.
|
|
378
|
+
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
379
|
+
const alive = await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs });
|
|
380
|
+
if (!alive) {
|
|
381
|
+
cache = { ...cache, at: 0 };
|
|
382
|
+
throw httpError(500, `client ${launchEngine.command} terminato subito: verifica login, provider, modello e argomenti dell'engine`);
|
|
383
|
+
}
|
|
384
|
+
|
|
374
385
|
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
375
386
|
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
376
387
|
let prompt = null;
|
|
@@ -379,7 +390,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
379
390
|
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
380
391
|
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
381
392
|
env: minimalEnv(),
|
|
382
|
-
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs :
|
|
393
|
+
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : readyMs,
|
|
383
394
|
target,
|
|
384
395
|
engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
385
396
|
});
|
package/lib/mcp/server.js
CHANGED
|
@@ -71,6 +71,79 @@ function requireSession(session, tool) {
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Deck layout is stored column-major, while the UI is read row-major. Preserve
|
|
75
|
+
// the visual order so an agent sees the same neighbourhood as the operator.
|
|
76
|
+
function orderedDeckMembers(deck) {
|
|
77
|
+
const columns = deck && deck.layout && Array.isArray(deck.layout.columns)
|
|
78
|
+
? deck.layout.columns : [];
|
|
79
|
+
const rows = Math.max(0, ...columns.map((column) => (
|
|
80
|
+
column && Array.isArray(column.tiles) ? column.tiles.length : 0
|
|
81
|
+
)));
|
|
82
|
+
const out = [];
|
|
83
|
+
for (let row = 0; row < rows; row += 1) {
|
|
84
|
+
for (const column of columns) {
|
|
85
|
+
const tile = column && Array.isArray(column.tiles) ? column.tiles[row] : null;
|
|
86
|
+
if (!tile || typeof tile.session !== 'string' || !tile.session) continue;
|
|
87
|
+
const member = { tmuxSession: tile.session };
|
|
88
|
+
if (typeof tile.node === 'string' && tile.node) member.node = tile.node;
|
|
89
|
+
if (typeof tile.ownerId === 'string' && NODE_ID_RE.test(tile.ownerId)) member.ownerId = tile.ownerId;
|
|
90
|
+
out.push(member);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
|
|
97
|
+
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
98
|
+
function fleetStatusPath(node) {
|
|
99
|
+
if (!node) return '/api/fleet/status';
|
|
100
|
+
const parts = String(node).split('/');
|
|
101
|
+
if (!parts.length || parts.some((part) => !NODE_PART_RE.test(part))
|
|
102
|
+
|| new Set(parts).size !== parts.length) return null;
|
|
103
|
+
return `/api/route/${parts.map(encodeURIComponent).join('/')}/_/fleet/status`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fleetCellsBySession(payload) {
|
|
107
|
+
const out = new Map();
|
|
108
|
+
if (!payload || payload.available !== true || !Array.isArray(payload.cells)) return out;
|
|
109
|
+
for (const cell of payload.cells) {
|
|
110
|
+
if (!cell || typeof cell.tmuxSession !== 'string' || !cell.tmuxSession
|
|
111
|
+
|| typeof cell.cell !== 'string' || !cell.cell) continue;
|
|
112
|
+
out.set(cell.tmuxSession, cell.cell);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function routePath(route, resource) {
|
|
118
|
+
if (!Array.isArray(route) || !route.length || route.length > 4
|
|
119
|
+
|| route.some((part) => !NODE_PART_RE.test(part)) || new Set(route).size !== route.length) return null;
|
|
120
|
+
return `/api/route/${route.map(encodeURIComponent).join('/')}/_/${resource}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function topologyOwners(payload) {
|
|
124
|
+
const out = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
for (const node of (payload && Array.isArray(payload.nodes) ? payload.nodes : [])) {
|
|
127
|
+
if (!node || !NODE_ID_RE.test(String(node.instanceId || '')) || seen.has(node.instanceId)
|
|
128
|
+
|| !Array.isArray(node.route) || !routePath(node.route, 'decks')) continue;
|
|
129
|
+
seen.add(node.instanceId);
|
|
130
|
+
out.push({
|
|
131
|
+
instanceId: node.instanceId,
|
|
132
|
+
route: [...node.route],
|
|
133
|
+
label: typeof node.label === 'string' && node.label ? node.label : (node.name || node.route.join(' › ')),
|
|
134
|
+
stale: node.stale === true,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function memberOwnerId(member, deckOwner, ownerTopology) {
|
|
141
|
+
if (member.ownerId && NODE_ID_RE.test(member.ownerId)) return member.ownerId;
|
|
142
|
+
if (!member.node) return deckOwner.instanceId;
|
|
143
|
+
const found = ownerTopology.find((node) => Array.isArray(node.route) && node.route.join('/') === member.node);
|
|
144
|
+
return found ? found.instanceId : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
74
147
|
// --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
|
|
75
148
|
// annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
|
|
76
149
|
const TOOLS = [
|
|
@@ -176,6 +249,94 @@ const TOOLS = [
|
|
|
176
249
|
return { sessions, fleet };
|
|
177
250
|
},
|
|
178
251
|
},
|
|
252
|
+
{
|
|
253
|
+
name: 'nc_deck',
|
|
254
|
+
description: 'Contesto read-only del deck della sessione chiamante: restituisce i deck che la contengono e i relativi membri con nome cella Fleet, sessione tmux e route.',
|
|
255
|
+
inputSchema: { type: 'object', properties: {} },
|
|
256
|
+
annotations: { readOnlyHint: true },
|
|
257
|
+
async handler(_args, ctx) {
|
|
258
|
+
const tmuxSession = requireSession(await ctx.session(), 'nc_deck');
|
|
259
|
+
const [config, topology, localDecks] = await Promise.all([
|
|
260
|
+
ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'), ctx.api('GET', '/api/decks'),
|
|
261
|
+
]);
|
|
262
|
+
const localNodeId = String(config && config.instanceId || '');
|
|
263
|
+
if (!NODE_ID_RE.test(localNodeId)) throw new Error('instanceId locale non disponibile');
|
|
264
|
+
if (!localDecks || !Array.isArray(localDecks.decks)) throw new Error('risposta deck non valida');
|
|
265
|
+
|
|
266
|
+
const remotes = topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localNodeId);
|
|
267
|
+
const viewerById = new Map([[localNodeId, []], ...remotes.map((owner) => [owner.instanceId, owner.route])]);
|
|
268
|
+
const sources = [{
|
|
269
|
+
owner: { instanceId: localNodeId, route: [], label: 'Local' },
|
|
270
|
+
ownerTopology: topologyOwners(topology), decks: localDecks.decks,
|
|
271
|
+
}];
|
|
272
|
+
await Promise.all(remotes.map(async (owner) => {
|
|
273
|
+
const decksPath = routePath(owner.route, 'decks');
|
|
274
|
+
const topologyPath = routePath(owner.route, 'topology');
|
|
275
|
+
if (!decksPath || !topologyPath) return;
|
|
276
|
+
try {
|
|
277
|
+
const [deckPayload, ownerTopology] = await Promise.all([
|
|
278
|
+
ctx.api('GET', decksPath), ctx.api('GET', topologyPath).catch(() => ({ nodes: [] })),
|
|
279
|
+
]);
|
|
280
|
+
if (deckPayload && Array.isArray(deckPayload.decks)) {
|
|
281
|
+
sources.push({ owner, ownerTopology: topologyOwners(ownerTopology), decks: deckPayload.decks });
|
|
282
|
+
}
|
|
283
|
+
} catch (_) { /* owner offline/withdrawn: no stale deck disclosure */ }
|
|
284
|
+
}));
|
|
285
|
+
|
|
286
|
+
const decks = [];
|
|
287
|
+
for (const source of sources) {
|
|
288
|
+
for (const deck of source.decks) {
|
|
289
|
+
if (!deck || typeof deck.name !== 'string') continue;
|
|
290
|
+
const members = orderedDeckMembers(deck).map((member) => ({
|
|
291
|
+
...member,
|
|
292
|
+
ownerId: memberOwnerId(member, source.owner, source.ownerTopology),
|
|
293
|
+
}));
|
|
294
|
+
if (!members.some((member) => member.ownerId === localNodeId && member.tmuxSession === tmuxSession)) continue;
|
|
295
|
+
decks.push({
|
|
296
|
+
id: `${source.owner.instanceId}:${deck.name}`,
|
|
297
|
+
name: deck.name,
|
|
298
|
+
owner: { instanceId: source.owner.instanceId, route: source.owner.route.length ? source.owner.route.join('/') : 'local', label: source.owner.label },
|
|
299
|
+
members,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
decks.sort((a, b) => a.owner.route.localeCompare(b.owner.route) || a.name.localeCompare(b.name));
|
|
304
|
+
|
|
305
|
+
if (!decks.length) return { tmuxSession, nodeId: localNodeId, decks: [] };
|
|
306
|
+
|
|
307
|
+
const routes = new Set(['']);
|
|
308
|
+
for (const deck of decks) for (const member of deck.members) {
|
|
309
|
+
const route = member.ownerId && viewerById.has(member.ownerId)
|
|
310
|
+
? viewerById.get(member.ownerId).join('/') : null;
|
|
311
|
+
member.viewerRoute = route;
|
|
312
|
+
if (route !== null) routes.add(route);
|
|
313
|
+
}
|
|
314
|
+
const cellsByRoute = new Map();
|
|
315
|
+
await Promise.all([...routes].map(async (route) => {
|
|
316
|
+
const apiPath = fleetStatusPath(route);
|
|
317
|
+
if (!apiPath) return;
|
|
318
|
+
try { cellsByRoute.set(route, fleetCellsBySession(await ctx.api('GET', apiPath))); }
|
|
319
|
+
catch (_) { cellsByRoute.set(route, new Map()); }
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
tmuxSession, nodeId: localNodeId,
|
|
324
|
+
decks: decks.map((deck) => ({
|
|
325
|
+
id: deck.id, name: deck.name, owner: deck.owner,
|
|
326
|
+
members: deck.members.map((member) => {
|
|
327
|
+
const route = member.viewerRoute;
|
|
328
|
+
return {
|
|
329
|
+
cell: route === null ? null : (cellsByRoute.get(route)?.get(member.tmuxSession) || null),
|
|
330
|
+
tmuxSession: member.tmuxSession,
|
|
331
|
+
ownerId: member.ownerId,
|
|
332
|
+
route: route === null ? 'unavailable' : (route || 'local'),
|
|
333
|
+
self: member.ownerId === localNodeId && member.tmuxSession === tmuxSession,
|
|
334
|
+
};
|
|
335
|
+
}),
|
|
336
|
+
})),
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
},
|
|
179
340
|
{
|
|
180
341
|
name: 'nc_inbox',
|
|
181
342
|
description: 'Elenca i file ricevuti nell\'inbox NexusCrew di questa sessione (read-only).',
|