@mmmbuto/nexuscrew 0.8.7 → 0.8.10

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.
@@ -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
  };
@@ -26,7 +26,7 @@ const path = require('node:path');
26
26
  const { execFile } = require('node:child_process');
27
27
  const {
28
28
  parseDefinitions, validateCommandTrust, resolveCwd,
29
- loadDefinitions, atomicWrite, CAPS, validTmuxName,
29
+ loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
30
30
  } = require('./definitions.js');
31
31
  const {
32
32
  publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
@@ -56,7 +56,7 @@ function minimalEnv() {
56
56
  return env;
57
57
  }
58
58
 
59
- function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
59
+ function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
60
60
 
61
61
  // Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
62
62
  // NON devono mai ecoare i segreti delle definizioni.
@@ -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 : 400,
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
  });
@@ -541,6 +552,59 @@ async function createBuiltinFleet(cfg = {}) {
541
552
  const sessions = await refreshSessions();
542
553
  return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
543
554
  }
555
+
556
+ // Ripristino selettivo atomico da backup PWA. Il body contiene SOLO campi
557
+ // cella allowlisted (mai engine.env/provider secrets). Tutte le celle vengono
558
+ // validate insieme da atomicWrite: un errore lascia il file precedente intatto.
559
+ async function restoreCells(cells) {
560
+ if (readonly()) throw httpError(403, 'READONLY: restore-cells bloccato');
561
+ if (!Array.isArray(cells) || cells.length < 1 || cells.length > MAX_CELLS) {
562
+ throw httpError(400, `cells deve contenere 1..${MAX_CELLS} definizioni`);
563
+ }
564
+ const allowed = new Set(['id', 'cwd', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'prompt']);
565
+ const seen = new Set();
566
+ for (const cell of cells) {
567
+ if (!cell || typeof cell !== 'object' || Array.isArray(cell)) throw httpError(400, 'definizione cell non valida');
568
+ for (const key of Object.keys(cell)) if (!allowed.has(key)) throw httpError(400, `campo backup non ammesso: ${key}`);
569
+ if (typeof cell.id !== 'string' || seen.has(cell.id)) throw httpError(400, `id cell duplicato o mancante: ${cell.id || '?'}`);
570
+ seen.add(cell.id);
571
+ }
572
+ const defs = reloadDefs();
573
+ const availableEngines = new Set(defs.engines.map((engine) => engine.id));
574
+ const referencedEngines = new Set();
575
+ for (const cell of cells) {
576
+ referencedEngines.add(cell.engine);
577
+ for (const id of Object.keys(cell.models || {})) referencedEngines.add(id);
578
+ for (const id of Object.keys(cell.permissionPolicies || {})) referencedEngines.add(id);
579
+ }
580
+ const missingEngines = [...referencedEngines].filter((id) => !availableEngines.has(id)).sort();
581
+ if (missingEngines.length) {
582
+ throw httpError(400, `engine mancanti sul target: ${missingEngines.join(', ')}`, {
583
+ code: 'missing-engines', missingEngines,
584
+ hint: 'definisci prima gli engine mancanti oppure mappa le celle su engine disponibili',
585
+ });
586
+ }
587
+ const replaced = cells.filter((cell) => !!findCell(defs, cell.id)).map((cell) => cell.id);
588
+ const created = cells.filter((cell) => !findCell(defs, cell.id)).map((cell) => cell.id);
589
+ await mutate(defs, (draft) => {
590
+ for (const cell of cells) {
591
+ const index = draft.cells.findIndex((current) => current.id === cell.id);
592
+ const next = { ...cell };
593
+ if (index >= 0) {
594
+ // tmuxSession e' identita' runtime immutabile: il backup non la porta.
595
+ next.tmuxSession = draft.cells[index].tmuxSession;
596
+ draft.cells[index] = next;
597
+ } else draft.cells.push(next);
598
+ }
599
+ });
600
+ const sessions = await refreshSessions();
601
+ const saved = reloadDefs();
602
+ return {
603
+ ok: true, count: cells.length, created, replaced,
604
+ needsRestart: cells.map((cell) => findCell(saved, cell.id))
605
+ .filter((cell) => cell && sessions.has(cell.tmuxSession)).map((cell) => cell.id),
606
+ };
607
+ }
544
608
  async function removeCell(id, opts = {}) {
545
609
  if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
546
610
  let defs = reloadDefs();
@@ -704,7 +768,7 @@ async function createBuiltinFleet(cfg = {}) {
704
768
  }
705
769
 
706
770
  function capabilities() {
707
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'schema', 'definitions'];
771
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions'];
708
772
  }
709
773
 
710
774
  return {
@@ -712,7 +776,7 @@ async function createBuiltinFleet(cfg = {}) {
712
776
  provider: 'builtin',
713
777
  status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
714
778
  defineEngine, editEngine, removeEngine,
715
- defineCell, editCell, removeCell, importCell,
779
+ defineCell, editCell, removeCell, importCell, restoreCells,
716
780
  schema, definitions, capabilities,
717
781
  };
718
782
  }
@@ -25,7 +25,9 @@ function requireCap(fleet, cap) {
25
25
  // async): ogni handler attende la resolve; unavailable → 404 sui comandi.
26
26
  function fleetRoutes(fleetP, cfg = {}) {
27
27
  const r = express.Router();
28
- r.use(express.json({ limit: '4kb' }));
28
+ const smallJson = express.json({ limit: '4kb' });
29
+ const restoreJson = express.json({ limit: '256kb' });
30
+ r.use((req, res, next) => (req.path === '/restore-cells' ? restoreJson : smallJson)(req, res, next));
29
31
 
30
32
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
31
33
 
@@ -38,7 +40,7 @@ function fleetRoutes(fleetP, cfg = {}) {
38
40
  if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
39
41
  res.json(await fn(fleet, req.body || {}));
40
42
  } catch (e) {
41
- res.status(e.status || 500).json({ error: String(e.message || e) });
43
+ res.status(e.status || 500).json({ error: String(e.message || e), ...(e.data || {}) });
42
44
  }
43
45
  };
44
46
 
@@ -110,10 +112,19 @@ function fleetRoutes(fleetP, cfg = {}) {
110
112
  r.post('/define-cell', guard((f, b) => { requireCap(f, 'define'); return f.defineCell(b.def); }, { mutate: true }));
111
113
  r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
112
114
  r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
115
+ r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
113
116
  // Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
114
117
  // gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
115
118
  r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
116
119
 
120
+ r.use((err, _req, res, _next) => {
121
+ if (err && (err.type === 'entity.too.large' || err.status === 413)) {
122
+ return res.status(413).json({ error: 'body troppo grande', code: 'body-too-large' });
123
+ }
124
+ if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido', code: 'invalid-json' });
125
+ return res.status(err.status || 400).json({ error: String(err.message || err) });
126
+ });
127
+
117
128
  return r;
118
129
  }
119
130
 
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).',