@mmmbuto/nexuscrew 0.9.62 → 0.9.64

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/mcp/cells.js CHANGED
@@ -100,12 +100,49 @@ function parseCellTarget(value) {
100
100
  return NODE_ID_RE.test(instanceId) && CELL_ID_RE.test(cell) ? { instanceId, cell } : null;
101
101
  }
102
102
 
103
+ const LIVE_MODES = new Set(['native', 'tmux']);
104
+ const LIVE_STATES = new Set(['present', 'active', 'tmux']);
105
+ const LIVE_REASONS = new Set(['live-not-addressable', 'tmux-mode']);
106
+
107
+ function normalizeLiveEntry(raw, owner, route) {
108
+ if (raw.instanceId !== owner.instanceId || raw.cell !== 'Live'
109
+ || !LIVE_MODES.has(raw.mode) || !LIVE_STATES.has(raw.state)) return null;
110
+ return {
111
+ id: `${owner.instanceId}:Live`,
112
+ instanceId: owner.instanceId,
113
+ owner: owner.label,
114
+ route,
115
+ cell: 'Live',
116
+ kind: 'live',
117
+ label: 'Live',
118
+ labelReported: false,
119
+ mode: raw.mode,
120
+ state: raw.state,
121
+ reason: LIVE_REASONS.has(raw.reason) ? raw.reason : 'live-not-addressable',
122
+ engine: '',
123
+ model: '',
124
+ active: raw.active === true,
125
+ // Not receivable until a dedicated delivery exists.
126
+ canReceive: false,
127
+ lastSeen: Number.isFinite(raw.lastSeen) ? raw.lastSeen : null,
128
+ self: false,
129
+ };
130
+ }
131
+
103
132
  function normalizeCellPayload(payload, owner, callerSession = null) {
104
133
  if (!payload || payload.instanceId !== owner.instanceId || !Array.isArray(payload.cells)) return [];
105
134
  const route = owner.route.length ? owner.route.join('/') : 'local';
106
135
  const seen = new Set();
107
136
  const out = [];
108
137
  for (const raw of payload.cells) {
138
+ if (raw && raw.kind === 'live') {
139
+ // The Live is not a cell: it has no tmux session. A federated payload is
140
+ // untrusted, so the entry is rebuilt from known values only and is never
141
+ // receivable, whatever the peer says.
142
+ const live = normalizeLiveEntry(raw, owner, route);
143
+ if (live && !seen.has('\0live')) { seen.add('\0live'); out.push(live); }
144
+ continue;
145
+ }
109
146
  if (!raw || raw.instanceId !== owner.instanceId || !CELL_ID_RE.test(String(raw.cell || ''))
110
147
  || typeof raw.tmuxSession !== 'string' || !isValidSession(raw.tmuxSession)
111
148
  || seen.has(raw.cell)) continue;
package/lib/mcp/server.js CHANGED
@@ -255,6 +255,11 @@ function createMcpServer(opts = {}) {
255
255
  // (config.json + env NEXUSCREW_CONFIG_FILE/PORT/TOKEN_FILE). opts.config per test.
256
256
  const cfg = opts.config || loadConfig();
257
257
  const baseUrl = `http://127.0.0.1:${cfg.port}`;
258
+ // The Live declares the reference of its own thread; the server decides
259
+ // whether the declaration holds (registry, host cell, live thread). A value
260
+ // that is not a well-formed reference is never sent.
261
+ const liveRefRaw = typeof env.NEXUSCREW_MCP_LIVE_THREAD === 'string' ? env.NEXUSCREW_MCP_LIVE_THREAD.trim() : '';
262
+ const liveThreadHeader = /^[a-f0-9]{32}$/.test(liveRefRaw) ? { 'x-nexuscrew-live-thread': liveRefRaw } : {};
258
263
 
259
264
  // Identita' locale storica: risolta una volta e cacheata — ma solo se riesce.
260
265
  //
@@ -375,6 +380,7 @@ function createMcpServer(opts = {}) {
375
380
  authorization: `Bearer ${token}`,
376
381
  ...signed,
377
382
  ...identityBinding,
383
+ ...liveThreadHeader,
378
384
  ...(payload !== undefined ? { 'content-type': 'application/json' } : {}),
379
385
  },
380
386
  ...(payload !== undefined ? { body: payload } : {}),
@@ -389,7 +395,7 @@ function createMcpServer(opts = {}) {
389
395
  return j;
390
396
  }
391
397
 
392
- // NC-R. Aggiornare NexusCrew NON cambia il codice gia' caricato dai bridge
398
+ // Aggiornare NexusCrew NON cambia il codice gia' caricato dai bridge
393
399
  // MCP delle celle attive: ogni processo resta sulla versione con cui e' stato
394
400
  // avviato fino al riavvio della cella. Dopo una correzione installata, un
395
401
  // bridge ancora attivo puo' quindi restituire l'errore precedente; prima di
package/lib/mcp/tools.js CHANGED
@@ -656,6 +656,7 @@ const TOOLS = [
656
656
  const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
657
657
  && cell.cell === targetRef.cell);
658
658
  if (!target) throw new Error('nc_cell_diagnostics: cella locale non trovata');
659
+ if (target.kind === 'live') throw new Error('nc_cell_diagnostics: la Live non e\' una cella e non ha diagnostica');
659
660
 
660
661
  const [definitions, diagnostics] = await Promise.all([
661
662
  ctx.api('GET', '/api/fleet/definitions'), recentDiagnostics(ctx),
@@ -712,6 +713,11 @@ const TOOLS = [
712
713
  const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
713
714
  && cell.cell === targetRef.cell);
714
715
  if (!target) throw new Error(cellLookupFailure(directory, targetRef));
716
+ // The Live is addressable by id but has no delivery yet: say so, and never
717
+ // let the message land on its host cell or any other.
718
+ if (target.kind === 'live') {
719
+ throw new Error(`live-not-addressable: la Live non riceve ancora messaggi da nc_send_cell (${target.reason || 'live-not-addressable'}); nessun messaggio inviato`);
720
+ }
715
721
  if (!target.canReceive) throw new Error('cella destinataria non attiva; nessun messaggio accodato');
716
722
  const apiPath = target.route === 'local'
717
723
  ? '/api/cells/send' : routePath(target.route.split('/'), 'cells/send');
@@ -923,7 +929,7 @@ const TOOLS = [
923
929
  return { receipt };
924
930
  },
925
931
  },
926
- // -- lease Live del child (fetta 2b, contratto rev1) ----------------------
932
+ // -- lease Live del child (fetta 2b) ----------------------
927
933
  // Tre metodi DISTINTI perche' i valori di ritorno non mentono l'uno con l'altro:
928
934
  // solo register puo' rispondere {status:'pending'} (join non ancora tracciato);
929
935
  // refresh su registration viva risponde {status:'live', proof} e non ha stati
package/lib/server.js CHANGED
@@ -31,6 +31,9 @@ const { cellsRoutes, publicCells } = require('./cells/routes.js');
31
31
  const { liveHostRoutes } = require('./live-host/routes.js');
32
32
  const { createLiveHostStore, liveHostPath } = require('./live-host/store.js');
33
33
  const { createLiveBridge } = require('./live-host/bridge.js');
34
+ const { createLiveThreadRegistry, liveThreadsPath } = require('./live-host/registry.js');
35
+ const { createLiveAttestation } = require('./live-host/attest.js');
36
+ const { buildLiveEntry } = require('./live-host/entry.js');
34
37
  const { createLiveHostFederation } = require('./live-host/federation.js');
35
38
  const { fsRoutes } = require('./fs/routes.js');
36
39
  const nodesStore = require('./nodes/store.js');
@@ -1017,7 +1020,7 @@ function createServer(opts = {}) {
1017
1020
  return cellScoper.resolve({ trust: 'federated', visited: [] });
1018
1021
  }
1019
1022
  };
1020
- // Scope celle (NC-E): un punto solo decide cosa un peer federato vede e puo'
1023
+ // Scope celle: un punto solo decide cosa un peer federato vede e puo'
1021
1024
  // toccare. Sta qui, prima di ogni route, perche' i canali da cui una cella
1022
1025
  // trapela sono molti e crescono: filtrarli uno per uno significa dimenticarne
1023
1026
  // uno, e un permesso che vale su quasi tutti i canali non e' un permesso.
@@ -1691,6 +1694,23 @@ function createServer(opts = {}) {
1691
1694
  }),
1692
1695
  getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
1693
1696
  }));
1697
+ // One store and one bridge serve the cell directory (the Live entry and the
1698
+ // verified label), the live-host routes and the federation forwarder. The
1699
+ // registry of Live threads is persisted next to the designation so a restart
1700
+ // does not make a living Live look absent.
1701
+ const liveHostStore = createLiveHostStore({ filePath: liveHostPath(cfg) });
1702
+ const liveRegistry = createLiveThreadRegistry({ filePath: liveThreadsPath(cfg) });
1703
+ const liveBridge = createLiveBridge({
1704
+ cfg,
1705
+ fleetP,
1706
+ tokenGet: () => tokenHolder.value,
1707
+ filesRoot: cfg.filesRoot,
1708
+ log: opts.log || console.log,
1709
+ registry: liveRegistry,
1710
+ });
1711
+ // Startup reconciliation never blocks the server and never throws: it only
1712
+ // drops entries the daemon definitely reports as gone.
1713
+ Promise.resolve().then(() => liveBridge.reconcile()).catch(() => {});
1694
1714
  api.use('/cells', cellsRoutes({
1695
1715
  fleetP,
1696
1716
  diagnostics,
@@ -1700,6 +1720,10 @@ function createServer(opts = {}) {
1700
1720
  engine: meta && meta.engine,
1701
1721
  })),
1702
1722
  readonly: proxyReadonly,
1723
+ liveEntry: ({ instanceId: id, now: clock }) => buildLiveEntry({
1724
+ store: liveHostStore, bridge: liveBridge, fleetP, instanceId: id, now: clock,
1725
+ }),
1726
+ liveAttest: createLiveAttestation({ registry: liveRegistry, bridge: liveBridge }),
1703
1727
  }));
1704
1728
  // Live host cell: one hostCell per node with CAS. The store
1705
1729
  // lives next to the token (same isolated dir in tests); the proxy refuses
@@ -1707,22 +1731,15 @@ function createServer(opts = {}) {
1707
1731
  // behind a per-peer permission denied by default (liveHostAccess).
1708
1732
  // Fetta 3: il ponte risolve il puntamento all'avvio di una Live (POST /bridge
1709
1733
  // nello stesso gruppo, stessa auth, stesso local-only). Isolabile da cfg
1710
- // (rev5): spento non crea connessioni e la Live resta standard.
1734
+ // Spento non crea connessioni e la Live resta standard.
1711
1735
  // One store instance serves both sides of a remote designation: the routes
1712
1736
  // (owner side) and the forwarder that records the pointer to a peer (client
1713
1737
  // side). Two instances over the same file would work, one is honest.
1714
- const liveHostStore = createLiveHostStore({ filePath: liveHostPath(cfg) });
1715
1738
  api.use('/live-host', liveHostRoutes({
1716
1739
  fleetP,
1717
1740
  store: liveHostStore,
1718
1741
  readonly: proxyReadonly,
1719
- bridge: createLiveBridge({
1720
- cfg,
1721
- fleetP,
1722
- tokenGet: () => tokenHolder.value,
1723
- filesRoot: cfg.filesRoot,
1724
- log: opts.log || console.log,
1725
- }),
1742
+ bridge: liveBridge,
1726
1743
  // Client side: read the owner's state and forward the bridge resolution to
1727
1744
  // the node the designation points at. Owner side: accept a forwarded
1728
1745
  // resolution behind the same per-peer gate and the same rate limiting as
@@ -161,7 +161,7 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
161
161
  // then the pane is revalidated before the separate Enter. Other clients
162
162
  // still receive a short separation between paste and submit.
163
163
  //
164
- // NC-Q: L'ATTESA SCALA COL PAYLOAD, e prima era una costante. Il tempo che
164
+ // L'ATTESA SCALA COL PAYLOAD, e prima era una costante. Il tempo che
165
165
  // un TUI impiega a ingerire un bracketed paste cresce con la lunghezza —
166
166
  // Claude Code sopra una certa taglia lo COLLASSA («paste again to expand»)
167
167
  // — mentre l'attesa era fissa. Oltre quella soglia l'Enter arriva mentre il
package/lib/tmux/list.js CHANGED
@@ -62,6 +62,15 @@ function isNoTmuxServerError(raw) {
62
62
  || /error connecting to .*(?:No such file or directory|Connection refused)/i.test(message);
63
63
  }
64
64
 
65
+ // NexusCrew starts tmux with `set -s exit-empty off`, so a live server with
66
+ // no sessions answers `list-panes -a` with "no current target" (not "no
67
+ // server running"): that is an empty inventory, not an error — otherwise the
68
+ // node would drop out of the fleet whenever its last session closes. This
69
+ // applies to list-panes ONLY: the other commands keep rejecting it.
70
+ function isNoCurrentTargetError(message) {
71
+ return /no current target/i.test(String(message || ''));
72
+ }
73
+
65
74
  function setSessionVisibility(tmuxBin = 'tmux', name, technical = false) {
66
75
  return new Promise((resolve, reject) => {
67
76
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name) || name.startsWith('-')) {
@@ -88,8 +97,9 @@ function listPanes(tmuxBin = 'tmux') {
88
97
  return new Promise((resolve, reject) => {
89
98
  execFile(tmuxBin, ['list-panes', '-a', '-F', PANES_FMT], (err, stdout, stderr) => {
90
99
  if (err) {
91
- if (isNoTmuxServerError(stderr || err.message)) return resolve('');
92
- return reject(new Error(`tmux list-panes failed: ${stderr || err.message}`));
100
+ const message = String(stderr || err.message);
101
+ if (isNoTmuxServerError(message) || isNoCurrentTargetError(message)) return resolve('');
102
+ return reject(new Error(`tmux list-panes failed: ${message}`));
93
103
  }
94
104
  resolve(stdout);
95
105
  });
@@ -144,8 +154,9 @@ function listFleetPanes(tmuxBin = 'tmux') {
144
154
  return new Promise((resolve, reject) => {
145
155
  execFile(tmuxBin, ['list-panes', '-a', '-F', FLEET_PANES_FMT], (err, stdout, stderr) => {
146
156
  if (err) {
147
- if (isNoTmuxServerError(stderr || err.message)) return resolve('');
148
- return reject(new Error(`tmux list-panes failed: ${stderr || err.message}`));
157
+ const message = String(stderr || err.message);
158
+ if (isNoTmuxServerError(message) || isNoCurrentTargetError(message)) return resolve('');
159
+ return reject(new Error(`tmux list-panes failed: ${message}`));
149
160
  }
150
161
  resolve(stdout);
151
162
  });
@@ -166,6 +177,6 @@ function attachedClients(tmuxBin = 'tmux', session) {
166
177
  }
167
178
 
168
179
  module.exports = {
169
- parsePaneTitle, parseSessions, isNoTmuxServerError, listSessions, listPanes, attachedClients, setSessionVisibility, FMT,
180
+ parsePaneTitle, parseSessions, isNoTmuxServerError, isNoCurrentTargetError, listSessions, listPanes, attachedClients, setSessionVisibility, FMT,
170
181
  FLEET_PANES_FMT, sessionsFromPanes, listFleetPanes,
171
182
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.62",
4
- "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
3
+ "version": "0.9.64",
4
+ "description": "Faithful browser tmux client \u2014 attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
7
7
  "nexuscrew": "bin/nexuscrew.js"