@mmmbuto/nexuscrew 0.8.16 → 0.8.18

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.
@@ -0,0 +1,300 @@
1
+ 'use strict';
2
+ // Registro TOOLS del server MCP (`lib/mcp/server.js`) + helper di validazione
3
+ // input/sessione fail-closed.
4
+ //
5
+ // Ogni tool e' prefissato `nc_` (anti-collisione) ed espone name/description/
6
+ // inputSchema/annotations (readOnlyHint sui tool read-only) + handler async.
7
+ // Gli handler ricevono `ctx` (session/api/home/fileExists/messageId) costruito
8
+ // dal server: qui NESSUN transport diretto, solo validazione bounded degli
9
+ // argomenti e orchestrazione delle chiamate via ctx.api. Ordine, nomi, schemi,
10
+ // handler, identity gate e semantica read/write sono preservati EXACT.
11
+ const path = require('node:path');
12
+ const {
13
+ NODE_ID_RE, orderedDeckMembers, fleetStatusPath, fleetCellsBySession,
14
+ routePath, topologyOwners, memberOwnerId, parseCellTarget, readCellDirectory,
15
+ } = require('./cells.js');
16
+
17
+ // --- helper input tool (fail-closed) ------------------------------------------
18
+ function argString(args, key, { required = false, max = 4096 } = {}) {
19
+ const v = args[key];
20
+ if (v === undefined || v === null) {
21
+ if (required) throw new Error(`parametro "${key}" obbligatorio`);
22
+ return undefined;
23
+ }
24
+ if (typeof v !== 'string') throw new Error(`parametro "${key}" deve essere una stringa`);
25
+ if (required && !v.trim()) throw new Error(`parametro "${key}" non puo' essere vuoto`);
26
+ if (v.length > max) throw new Error(`parametro "${key}" troppo lungo (max ${max})`);
27
+ return v;
28
+ }
29
+
30
+ function requireSession(session, tool) {
31
+ if (session) return session;
32
+ throw new Error(
33
+ `${tool}: sessione tmux non identificata — serve $TMUX (dentro tmux) o NEXUSCREW_MCP_SESSION`,
34
+ );
35
+ }
36
+
37
+ // --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
38
+ // annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
39
+ const TOOLS = [
40
+ {
41
+ name: 'nc_notify',
42
+ description: 'Invia una notifica all\'operatore via NexusCrew (UI aperte + web push). Usala per esiti, avvisi e richieste di attenzione non bloccanti.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ title: { type: 'string', description: 'titolo breve della notifica' },
47
+ body: { type: 'string', description: 'dettaglio opzionale' },
48
+ urgency: { type: 'string', enum: ['normal', 'high'], description: 'default normal' },
49
+ },
50
+ required: ['title'],
51
+ },
52
+ async handler(args, ctx) {
53
+ const title = argString(args, 'title', { required: true, max: 200 });
54
+ const body = argString(args, 'body', { max: 2000 });
55
+ const urgency = argString(args, 'urgency', { max: 16 });
56
+ if (urgency !== undefined && urgency !== 'normal' && urgency !== 'high') {
57
+ throw new Error('urgency deve essere "normal" o "high"');
58
+ }
59
+ const session = await ctx.session();
60
+ const payload = { title, ...(body ? { body } : {}), ...(urgency ? { urgency } : {}) };
61
+ if (session) payload.session = session;
62
+ const j = await ctx.api('POST', '/api/notify', payload);
63
+ return { delivered: j.delivered };
64
+ },
65
+ },
66
+ {
67
+ name: 'nc_ask',
68
+ description: 'Pone una domanda all\'operatore e ritorna SUBITO (non bloccare in attesa): la risposta arrivera\' come messaggio incollato in questa sessione tmux, prefissato [human reply · ask#<id>] per default.',
69
+ inputSchema: {
70
+ type: 'object',
71
+ properties: {
72
+ question: { type: 'string', description: 'la domanda per l\'operatore' },
73
+ options: { type: 'array', items: { type: 'string' }, description: 'opzioni di risposta rapida (max 8)' },
74
+ },
75
+ required: ['question'],
76
+ },
77
+ async handler(args, ctx) {
78
+ const question = argString(args, 'question', { required: true, max: 2000 });
79
+ if (args.options !== undefined && !Array.isArray(args.options)) {
80
+ throw new Error('options deve essere un array di stringhe');
81
+ }
82
+ const session = requireSession(await ctx.session(), 'nc_ask');
83
+ const j = await ctx.api('POST', '/api/asks', {
84
+ question, ...(args.options ? { options: args.options } : {}), session,
85
+ });
86
+ return {
87
+ askId: j.id,
88
+ note: 'la risposta dell\'operatore arrivera\' come messaggio incollato in questa sessione tmux',
89
+ };
90
+ },
91
+ },
92
+ {
93
+ name: 'nc_send_file',
94
+ description: 'Consegna un file all\'operatore: lo copia nell\'outbox NexusCrew di questa sessione (badge + notifica automatici). Path assoluto sotto HOME.',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ path: { type: 'string', description: 'path assoluto del file (sotto HOME)' },
99
+ caption: { type: 'string', description: 'didascalia opzionale' },
100
+ },
101
+ required: ['path'],
102
+ },
103
+ async handler(args, ctx) {
104
+ const p = argString(args, 'path', { required: true, max: 4096 });
105
+ const caption = argString(args, 'caption', { max: 500 });
106
+ if (!path.isAbsolute(p)) throw new Error('path deve essere assoluto');
107
+ // Pre-check locale (errore immediato e chiaro); la validazione autoritativa
108
+ // (realpath sotto HOME, file regolare) la rifa' comunque il server.
109
+ if (!ctx.fileExists(p)) throw new Error(`file inesistente: ${p}`);
110
+ const home = ctx.home();
111
+ if (p !== home && !p.startsWith(home + path.sep)) throw new Error('path fuori da HOME');
112
+ const session = requireSession(await ctx.session(), 'nc_send_file');
113
+ const j = await ctx.api('POST', '/api/files/outbox', {
114
+ session, path: p, ...(caption ? { caption } : {}),
115
+ });
116
+ return { name: j.name, box: 'outbox' };
117
+ },
118
+ },
119
+ {
120
+ name: 'nc_status',
121
+ description: 'Stato read-only di NexusCrew: sessioni tmux vive e celle della flotta (se abilitata).',
122
+ inputSchema: { type: 'object', properties: {} },
123
+ annotations: { readOnlyHint: true },
124
+ async handler(_args, ctx) {
125
+ const s = await ctx.api('GET', '/api/sessions');
126
+ const sessions = (Array.isArray(s.sessions) ? s.sessions : [])
127
+ .map((x) => ({ name: x.name, active: !!x.attached }));
128
+ let fleet = null;
129
+ try {
130
+ const f = await ctx.api('GET', '/api/fleet/status');
131
+ if (f && f.available) {
132
+ fleet = {
133
+ cells: (Array.isArray(f.cells) ? f.cells : []).map((c) => ({
134
+ cell: c.cell, session: c.tmuxSession, engine: c.engine, active: !!c.active,
135
+ })),
136
+ };
137
+ }
138
+ } catch (_) { /* fleet opzionale: assente/disabilitata -> null */ }
139
+ return { sessions, fleet };
140
+ },
141
+ },
142
+ {
143
+ name: 'nc_deck',
144
+ 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.',
145
+ inputSchema: { type: 'object', properties: {} },
146
+ annotations: { readOnlyHint: true },
147
+ async handler(_args, ctx) {
148
+ const tmuxSession = requireSession(await ctx.session(), 'nc_deck');
149
+ const [config, topology, localDecks] = await Promise.all([
150
+ ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'), ctx.api('GET', '/api/decks'),
151
+ ]);
152
+ const localNodeId = String(config && config.instanceId || '');
153
+ if (!NODE_ID_RE.test(localNodeId)) throw new Error('instanceId locale non disponibile');
154
+ if (!localDecks || !Array.isArray(localDecks.decks)) throw new Error('risposta deck non valida');
155
+
156
+ const remotes = topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localNodeId);
157
+ const viewerById = new Map([[localNodeId, []], ...remotes.map((owner) => [owner.instanceId, owner.route])]);
158
+ const sources = [{
159
+ owner: { instanceId: localNodeId, route: [], label: 'Local' },
160
+ ownerTopology: topologyOwners(topology), decks: localDecks.decks,
161
+ }];
162
+ await Promise.all(remotes.map(async (owner) => {
163
+ const decksPath = routePath(owner.route, 'decks');
164
+ const topologyPath = routePath(owner.route, 'topology');
165
+ if (!decksPath || !topologyPath) return;
166
+ try {
167
+ const [deckPayload, ownerTopology] = await Promise.all([
168
+ ctx.api('GET', decksPath), ctx.api('GET', topologyPath).catch(() => ({ nodes: [] })),
169
+ ]);
170
+ if (deckPayload && Array.isArray(deckPayload.decks)) {
171
+ sources.push({ owner, ownerTopology: topologyOwners(ownerTopology), decks: deckPayload.decks });
172
+ }
173
+ } catch (_) { /* owner offline/withdrawn: no stale deck disclosure */ }
174
+ }));
175
+
176
+ const decks = [];
177
+ for (const source of sources) {
178
+ for (const deck of source.decks) {
179
+ if (!deck || typeof deck.name !== 'string') continue;
180
+ const members = orderedDeckMembers(deck).map((member) => ({
181
+ ...member,
182
+ ownerId: memberOwnerId(member, source.owner, source.ownerTopology),
183
+ }));
184
+ if (!members.some((member) => member.ownerId === localNodeId && member.tmuxSession === tmuxSession)) continue;
185
+ decks.push({
186
+ id: `${source.owner.instanceId}:${deck.name}`,
187
+ name: deck.name,
188
+ owner: { instanceId: source.owner.instanceId, route: source.owner.route.length ? source.owner.route.join('/') : 'local', label: source.owner.label },
189
+ members,
190
+ });
191
+ }
192
+ }
193
+ decks.sort((a, b) => a.owner.route.localeCompare(b.owner.route) || a.name.localeCompare(b.name));
194
+
195
+ if (!decks.length) return { tmuxSession, nodeId: localNodeId, decks: [] };
196
+
197
+ const routes = new Set(['']);
198
+ for (const deck of decks) for (const member of deck.members) {
199
+ const route = member.ownerId && viewerById.has(member.ownerId)
200
+ ? viewerById.get(member.ownerId).join('/') : null;
201
+ member.viewerRoute = route;
202
+ if (route !== null) routes.add(route);
203
+ }
204
+ const cellsByRoute = new Map();
205
+ await Promise.all([...routes].map(async (route) => {
206
+ const apiPath = fleetStatusPath(route);
207
+ if (!apiPath) return;
208
+ try { cellsByRoute.set(route, fleetCellsBySession(await ctx.api('GET', apiPath))); }
209
+ catch (_) { cellsByRoute.set(route, new Map()); }
210
+ }));
211
+
212
+ return {
213
+ tmuxSession, nodeId: localNodeId,
214
+ decks: decks.map((deck) => ({
215
+ id: deck.id, name: deck.name, owner: deck.owner,
216
+ members: deck.members.map((member) => {
217
+ const route = member.viewerRoute;
218
+ return {
219
+ cell: route === null ? null : (cellsByRoute.get(route)?.get(member.tmuxSession) || null),
220
+ tmuxSession: member.tmuxSession,
221
+ ownerId: member.ownerId,
222
+ route: route === null ? 'unavailable' : (route || 'local'),
223
+ self: member.ownerId === localNodeId && member.tmuxSession === tmuxSession,
224
+ };
225
+ }),
226
+ })),
227
+ };
228
+ },
229
+ },
230
+ {
231
+ name: 'nc_cells',
232
+ description: 'Directory read-only di tutte le celle Fleet autorizzate nella rete NexusCrew. Usa l\'id owner-qualified restituito per evitare nomi ambigui.',
233
+ inputSchema: { type: 'object', properties: {} },
234
+ annotations: { readOnlyHint: true },
235
+ async handler(_args, ctx) {
236
+ const callerSession = await ctx.session();
237
+ return readCellDirectory(ctx, callerSession);
238
+ },
239
+ },
240
+ {
241
+ name: 'nc_send_cell',
242
+ description: 'Invia e sottopone un messaggio a una cella Fleet attiva autorizzata. target deve essere l\'id esatto restituito da nc_cells; submitted non significa lavoro completato.',
243
+ inputSchema: {
244
+ type: 'object',
245
+ properties: {
246
+ target: { type: 'string', description: 'id owner-qualified restituito da nc_cells: <instanceId>:<cell>' },
247
+ message: { type: 'string', description: 'messaggio o task da sottoporre (max 8000 caratteri)' },
248
+ },
249
+ required: ['target', 'message'],
250
+ },
251
+ async handler(args, ctx) {
252
+ const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
253
+ if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
254
+ const message = argString(args, 'message', { required: true, max: 8000 });
255
+ for (let i = 0; i < message.length; i += 1) {
256
+ const code = message.charCodeAt(i);
257
+ if (code === 9 || code === 10 || code === 13) continue;
258
+ if (code < 32 || code === 127) throw new Error('message contiene caratteri di controllo non ammessi');
259
+ }
260
+ const callerSession = requireSession(await ctx.session(), 'nc_send_cell');
261
+ const directory = await readCellDirectory(ctx, callerSession);
262
+ const sender = directory.cells.find((cell) => cell.self && cell.active);
263
+ if (!sender) throw new Error('nc_send_cell: la sessione chiamante non e\' una cella Fleet attiva locale');
264
+ const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
265
+ && cell.cell === targetRef.cell);
266
+ if (!target) throw new Error('cella destinataria non trovata nella rete autorizzata');
267
+ if (!target.canReceive) throw new Error('cella destinataria non attiva; nessun messaggio accodato');
268
+ const apiPath = target.route === 'local'
269
+ ? '/api/cells/send' : routePath(target.route.split('/'), 'cells/send');
270
+ if (!apiPath) throw new Error('route destinataria non valida');
271
+ const id = ctx.messageId();
272
+ const receipt = await ctx.api('POST', apiPath, {
273
+ id,
274
+ from: { instanceId: sender.instanceId, cell: sender.cell, tmuxSession: sender.tmuxSession },
275
+ to: { instanceId: target.instanceId, cell: target.cell, tmuxSession: target.tmuxSession },
276
+ message,
277
+ });
278
+ return {
279
+ id: receipt.id,
280
+ status: receipt.status,
281
+ at: receipt.at,
282
+ to: receipt.to,
283
+ note: receipt.note || 'submitted conferma il trasporto, non il completamento del task',
284
+ };
285
+ },
286
+ },
287
+ {
288
+ name: 'nc_inbox',
289
+ description: 'Elenca i file ricevuti nell\'inbox NexusCrew di questa sessione (read-only).',
290
+ inputSchema: { type: 'object', properties: {} },
291
+ annotations: { readOnlyHint: true },
292
+ async handler(_args, ctx) {
293
+ const session = requireSession(await ctx.session(), 'nc_inbox');
294
+ const j = await ctx.api('GET', `/api/files?session=${encodeURIComponent(session)}`);
295
+ return { inbox: Array.isArray(j.inbox) ? j.inbox : [] };
296
+ },
297
+ },
298
+ ];
299
+
300
+ module.exports = { TOOLS, argString, requireSession };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const os = require('node:os');
4
+ const path = require('node:path');
4
5
 
5
6
  const MINIMAL_ENV_KEYS = Object.freeze([
6
7
  'PATH', 'HOME', 'SHELL', 'TERM', 'COLORTERM', 'LANG', 'LANGUAGE',
@@ -37,6 +38,33 @@ function withUtf8Locale(source = process.env, { platform = process.platform } =
37
38
  return env;
38
39
  }
39
40
 
41
+ // Termux installs tmux with TMUX_TMPDIR=$PREFIX/var/run via a profile.d
42
+ // snippet. Background runtimes and Termux:Boot do not necessarily source that
43
+ // profile, so a first `tmux new-session` would otherwise try the Android/FHS
44
+ // fallback instead of the canonical Termux socket directory. Derive only from
45
+ // an explicit PREFIX or the standard .../files/home layout; never guess paths
46
+ // for Linux/macOS.
47
+ function termuxRuntimePaths(source = process.env, opts = {}) {
48
+ const platform = opts.platform || process.platform;
49
+ const home = String(source.HOME || opts.home || os.homedir());
50
+ const suppliedPrefix = String(source.PREFIX || '');
51
+ const termux = platform === 'android'
52
+ || suppliedPrefix.includes('com.termux')
53
+ || (path.basename(home) === 'home' && path.basename(path.dirname(home)) === 'files');
54
+ if (!termux) return null;
55
+
56
+ let prefix = suppliedPrefix;
57
+ if (!prefix && path.basename(home) === 'home' && path.basename(path.dirname(home)) === 'files') {
58
+ prefix = path.join(path.dirname(home), 'usr');
59
+ }
60
+ if (!path.isAbsolute(prefix)) return null;
61
+ return {
62
+ prefix,
63
+ tmpdir: String(source.TMPDIR || path.join(prefix, 'tmp')),
64
+ tmuxTmpdir: String(source.TMUX_TMPDIR || path.join(prefix, 'var', 'run')),
65
+ };
66
+ }
67
+
40
68
  function minimalRuntimeEnv(source = process.env, opts = {}) {
41
69
  const selected = {};
42
70
  for (const key of MINIMAL_ENV_KEYS) {
@@ -44,7 +72,16 @@ function minimalRuntimeEnv(source = process.env, opts = {}) {
44
72
  }
45
73
  if (!selected.PATH) selected.PATH = '/usr/local/bin:/usr/bin:/bin';
46
74
  if (!selected.HOME) selected.HOME = opts.home || os.homedir();
75
+ const termux = termuxRuntimePaths(selected, opts);
76
+ if (termux) {
77
+ if (!selected.PREFIX) selected.PREFIX = termux.prefix;
78
+ if (!selected.TMPDIR) selected.TMPDIR = termux.tmpdir;
79
+ if (!selected.TMUX_TMPDIR) selected.TMUX_TMPDIR = termux.tmuxTmpdir;
80
+ }
47
81
  return withUtf8Locale(selected, opts);
48
82
  }
49
83
 
50
- module.exports = { MINIMAL_ENV_KEYS, UTF8_RE, localeDefaults, withUtf8Locale, minimalRuntimeEnv };
84
+ module.exports = {
85
+ MINIMAL_ENV_KEYS, UTF8_RE, localeDefaults, withUtf8Locale,
86
+ termuxRuntimePaths, minimalRuntimeEnv,
87
+ };