@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/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? }. B0 aggiungerà i check tunnel/ssh (permitlisten,
5
- // OpenSSH ≥7.8 sul rendezvous) appendendo alla lista, senza toccare il resto.
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: 'ssh client presente', ok: true }
91
- : { name: 'ssh client presente', ok: false, detail: 'ssh non trovato su PATH (necessario per i tunnel multi-node)' };
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
- // OpenSSH >=7.8 per permitlisten (vincola i -R lato rendezvous; design §7 (a)).
95
- // Determinabile e <7.8 -> FAIL (il ruolo node non e' abilitabile). Non
96
- // determinabile -> WARN (verifica manuale), non blocca la diagnosi generale.
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
- const supp = tun.sshSupportsPermitlisten(v);
101
- if (supp === null) {
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
- checkSshClient, checkSshPermitlisten,
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 tre client nativi. Provider cloud/Z.AI sono
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
  }
@@ -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 meta = JSON.stringify({ pid, cmd: cmd || '', startTs: Date.now() });
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
 
@@ -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
  };
@@ -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
  };
@@ -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
  });
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).',