@mmmbuto/nexuscrew 0.9.63 → 0.9.65

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -21
  3. package/docs/NOTIFICATIONS.md +34 -0
  4. package/docs/SECURITY.md +13 -12
  5. package/frontend/dist/assets/index-DkYRC1vV.js +169 -0
  6. package/frontend/dist/assets/{index-D1cTtNED.css → index-Dwd37Z_R.css} +2 -2
  7. package/frontend/dist/index.html +2 -2
  8. package/frontend/dist/version.json +1 -1
  9. package/lib/cells/routes.js +37 -3
  10. package/lib/cli/commands.js +1 -1
  11. package/lib/config.js +1 -1
  12. package/lib/fleet/builtin.js +3 -0
  13. package/lib/fleet/cell-lease-server.js +1 -1
  14. package/lib/fleet/cell-lease.js +1 -1
  15. package/lib/fleet/lease-client.js +1 -1
  16. package/lib/fleet/lease-verifier.js +1 -1
  17. package/lib/live-host/attest.js +39 -0
  18. package/lib/live-host/bridge.js +81 -21
  19. package/lib/live-host/entry.js +49 -0
  20. package/lib/live-host/registry.js +111 -0
  21. package/lib/live-host/reserved.js +12 -0
  22. package/lib/mcp/cells.js +37 -0
  23. package/lib/mcp/server.js +7 -1
  24. package/lib/mcp/tools.js +7 -1
  25. package/lib/notify/ask-relay.js +36 -166
  26. package/lib/notify/event-feed-asks-routes.js +18 -3
  27. package/lib/notify/event-feed-client.js +85 -10
  28. package/lib/notify/event-feed-notices-routes.js +135 -0
  29. package/lib/notify/event-feed-producers.js +21 -0
  30. package/lib/notify/event-feed-routes.js +30 -11
  31. package/lib/notify/notice-dismissal-queue.js +336 -0
  32. package/lib/notify/notice-dismissals.js +178 -0
  33. package/lib/notify/notice-relay.js +128 -0
  34. package/lib/notify/owner-channel.js +172 -0
  35. package/lib/proxy/federation.js +9 -0
  36. package/lib/proxy/resource-acl.js +6 -0
  37. package/lib/server.js +229 -12
  38. package/lib/tmux/actions.js +1 -1
  39. package/package.json +2 -2
  40. package/frontend/dist/assets/index-maPH0v_e.js +0 -169
@@ -2,7 +2,7 @@
2
2
 
3
3
  // Pure state transitions for the supervisor lease of a Live host cell.
4
4
  //
5
- // Contratto rev12 + rev13 + rev22. Il canale lease e' la connessione UDS
5
+ // Il canale lease e' la connessione UDS
6
6
  // accettata col nonce one-shot del launch-broker che RESTA APERTA dopo il frame
7
7
  // payload; after an EOF the supervisor reconnects to a stable endpoint.
8
8
  // Here live ONLY the lease state transitions: the side
@@ -6,7 +6,7 @@
6
6
  // stays OPEN after the payload), sends refreshes every 20s (heartbeat),
7
7
  // and on EOF reconnects to the stable endpoint.
8
8
  //
9
- // Fetta 2b (contratto rev1, /): l'autenticazione del reconnect e' un proof
9
+ // Fetta 2b: l'autenticazione del reconnect e' un proof
10
10
  // HMAC firmato dal server col verifier per-installazione — la capability statica
11
11
  // della 2a e' revocata. Il proof NON arriva nel payload: il server lo consegna
12
12
  // sul canale (frame lease all'attach, ack a ogni refresh) e il supervisore lo
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // Verifier per-installazione e proof HMAC del lease Live (fetta 2b, contratto
4
- // Contract rev1: the premise and the items below.
4
+ // The premise and the items below.
5
5
  //
6
6
  // Modello (PREMESSA): la 2a usava un segreto condiviso simmetrico — il
7
7
  // supervisore presentava la capability cosi' com'e' e il server la confrontava.
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+ // lib/live-host/attest.js — verifica del riferimento Live dichiarato da un mittente.
3
+ //
4
+ // Il server MCP della Live dichiara il riferimento della propria thread; qui si
5
+ // decide se quella dichiarazione regge. Regge solo se il riferimento e' nel
6
+ // registro, appartiene alla cella ospite che sta mandando il messaggio e la
7
+ // thread e' viva adesso. Il risultato porta un motivo nominato, ma chi lo usa
8
+ // non rifiuta il messaggio: lo consegna senza etichetta.
9
+
10
+ const { REF_RE } = require('./registry.js');
11
+
12
+ const ALIVE = new Set(['present', 'active']);
13
+
14
+ function createLiveAttestation({ registry, bridge }) {
15
+ async function verify({ ref, fromCell } = {}) {
16
+ if (ref === undefined || ref === null || ref === '') return { ok: false, reason: 'no-ref' };
17
+ if (typeof ref !== 'string' || !REF_RE.test(ref)) return { ok: false, reason: 'bad-ref' };
18
+ const entry = registry.findByRef(ref);
19
+ if (!entry) return { ok: false, reason: 'unknown-ref' };
20
+ if (entry.cell !== fromCell) return { ok: false, reason: 'wrong-host' };
21
+ // La tupla (cella, thread, riferimento) e' quella letta ADESSO: e' lei che
22
+ // si attesta. Lo stato si chiede per quel thread, e dopo l'attesa il
23
+ // registro si rilegge: se nel frattempo la Live e' stata ruotata, la
24
+ // risposta non vale piu' per questo riferimento.
25
+ const captured = { cell: entry.cell, threadId: entry.threadId };
26
+ let status;
27
+ try { status = await bridge.threadStatus(captured.cell, captured.threadId); } catch (_) { status = 'unknown'; }
28
+ // Un thread non vivo non da mai l'etichetta (anche se il ponte lo ha appena tolto dal registro).
29
+ if (!ALIVE.has(status)) return { ok: false, reason: 'thread-not-alive' };
30
+ const now = registry.findByRef(ref);
31
+ if (!now || now.cell !== captured.cell || now.threadId !== captured.threadId) {
32
+ return { ok: false, reason: 'ref-changed' };
33
+ }
34
+ return { ok: true, hostCell: captured.cell };
35
+ }
36
+ return { verify };
37
+ }
38
+
39
+ module.exports = { createLiveAttestation };
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // lib/live-host/bridge.js — the Live bridge (contract revision rev6 + rev4 LC).
2
+ // lib/live-host/bridge.js — the Live bridge (contract revisions).
3
3
  //
4
4
  // Exposure: POST /api/live-host/bridge, mounted in routes.js. Reaching
5
5
  // /api/live-host directly stays a local-only path (LOCAL_ONLY_PREFIXES covers
@@ -28,8 +28,7 @@
28
28
  // puntamento, comportamento standard. Un `none` non è un errore HTTP.
29
29
  // Il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
30
30
  // cella) viaggia su developerInstructions di thread/start e SOSTITUISCE
31
- // Le developer instructions della config per quella Live (rev4
32
- // Emendata da rev5). La riga che decide è in codex-rs
31
+ // Le developer instructions della config per quella Live. La riga che decide è in codex-rs
33
32
  // core/src/config/mod.rs: `developer_instructions.or(cfg.developer_
34
33
  // Instructions)` — l'override Some scarta il valore di config.
35
34
  // (verso corretto dopo revisione pre-release): l'identità della
@@ -46,10 +45,10 @@
46
45
  // thread/read e' separata e sola lettura: non modifica il thread ponte ne'
47
46
  // quello della TUI. Per questo l'aggancio funziona anche su una cella che
48
47
  // sta già processando un turno: conversazioni separate, nessuna
49
- // Interruzione (rev1 /rev2).
48
+ // Interruzione.
50
49
  // La connessione al socket di controllo è ON-DEMAND (connect →
51
50
  // handshake → thread/start → close), mai permanente: la fuga notifiche
52
- // notata in rev5 riguarda i client permanenti.
51
+ // notata in precedenza riguarda i client permanenti.
53
52
  // Il ponte opera SOLO sulla cella designata — non accetta target
54
53
  // Dal chiamante, la designazione è la condizione.
55
54
  // Isolabile — cfg.liveBridgeEnabled=false e il ponte non si connette
@@ -66,6 +65,7 @@
66
65
  const fs = require('node:fs');
67
66
  const path = require('node:path');
68
67
  const os = require('node:os');
68
+ const crypto = require('node:crypto');
69
69
 
70
70
  // ws+unix:// è supportato nativamente da ws >= 8 (isIpcUrl): il path prima dei
71
71
  // `:` è il socket, dopo è la resource. Iniettabile per i test.
@@ -174,7 +174,7 @@ function queryThreadStatusOnControlSocket({
174
174
  });
175
175
  }
176
176
 
177
- // — Prompt per-cella (rev4, nome fisso confermato il 2026-08-15) ——
177
+ // — Prompt per-cella (nome fisso, confermato il 2026-08-15) ——
178
178
  // Collocazione: filesRoot/<tmuxSession>/LIVE_PROMPT.md — la sessione tmux
179
179
  // ESATTA che il roster dichiara per la cella designata, la stessa fonte gia'
180
180
  // Usata per l'intestazione (identityHeader). NON un prefisso ricostruito a
@@ -248,7 +248,7 @@ function readCellPrompt(filesRoot, tmuxSession) {
248
248
  // Senza tmuxSession dichiarata non c'è identità possibile: il testo lo DICE,
249
249
  // non suggerisce un comando che fallirebbe comunque (e una sessione indovinata
250
250
  // sarebbe l'identità di un'altra cella).
251
- function identityHeader(cellId, tmuxSession) {
251
+ function identityHeader(cellId, tmuxSession, liveRef = null) {
252
252
  if (!tmuxSession) {
253
253
  return `Live NexusCrew agganciata alla cella ${cellId}. `
254
254
  + 'Il roster non dichiara una sessione tmux per questa cella: senza sessione '
@@ -259,9 +259,10 @@ function identityHeader(cellId, tmuxSession) {
259
259
  + 'Questa Live eredita l\'ambiente del daemon, condiviso fra tutte le Live e senza identità: '
260
260
  + 'i tool NexusCrew che richiedono la sessione restano chiusi finché non li chiami con la tua. '
261
261
  + 'Per usarli avvia il server MCP NexusCrew via stdio con la sessione di questa cella '
262
- + `nell'ambiente — NEXUSCREW_MCP_SESSION=${tmuxSession} nexuscrew mcp — e parlagli `
262
+ + `nell'ambiente — NEXUSCREW_MCP_SESSION=${tmuxSession}${liveRef ? ` NEXUSCREW_MCP_LIVE_THREAD=${liveRef}` : ''} nexuscrew mcp — e parlagli `
263
263
  + 'JSON-RPC su stdin (initialize, notifications/initialized, tools/call). '
264
- + `Il valore esatto per questa conversazione è ${tmuxSession}: mai un'altra sessione.`;
264
+ + `Il valore esatto per questa conversazione è ${tmuxSession}: mai un'altra sessione.`
265
+ + (liveRef ? ' NEXUSCREW_MCP_LIVE_THREAD dichiara questa Live ai messaggi che mandi: tienilo uguale, senza modificarlo.' : '');
265
266
  }
266
267
 
267
268
  // —— Client on-demand del socket di controllo (sezione protocollo sopra) ——
@@ -411,6 +412,18 @@ function startThreadOnControlSocket({
411
412
  });
412
413
  }
413
414
 
415
+ // Registro volatile con la stessa interfaccia di quello persistito.
416
+ function volatileRegistry(now) {
417
+ const map = new Map();
418
+ return {
419
+ get: (cell) => map.get(cell) || null,
420
+ set: (cell, entry) => { const v = { ...entry, startedAt: now() }; map.set(cell, v); return v; },
421
+ remove: (cell) => map.delete(cell),
422
+ entries: () => [...map].map(([cell, entry]) => ({ cell, ...entry })),
423
+ findByRef: (ref) => [...map].map(([cell, entry]) => ({ cell, ...entry })).find((e) => e.ref === ref) || null,
424
+ };
425
+ }
426
+
414
427
  // —— Il ponte ——
415
428
  function createLiveBridge({
416
429
  cfg,
@@ -421,9 +434,12 @@ function createLiveBridge({
421
434
  WebSocket,
422
435
  now = () => Date.now(),
423
436
  log = () => {},
437
+ registry = null,
424
438
  }) {
425
439
  const root = filesRoot || cfg.filesRoot || path.join(os.homedir(), 'NexusFiles');
426
- const threadIdsByCell = new Map();
440
+ // The persisted registry survives a restart; without one (tests, partial
441
+ // setups) the bridge keeps the old behaviour with a volatile store.
442
+ const reg = registry || volatileRegistry(now);
427
443
  const threadStatusCache = new Map();
428
444
  const threadStatusInFlight = new Map();
429
445
  // Riserve in-process per l'avvio Live. La chiave è la cella host: una
@@ -497,15 +513,28 @@ function createLiveBridge({
497
513
  return cells.find((c) => c && c.cell === cellId) || null;
498
514
  }
499
515
 
500
- async function threadStatus(cellId) {
501
- const threadId = threadIdsByCell.get(cellId);
516
+ // Cache e richieste in corso sono per TUPLA (cella, thread), non per sola
517
+ // cella: la lettura di un thread sostituito non puo' rispondere per il nuovo,
518
+ // ne' ripopolare la cache dopo la rotazione.
519
+ const statusKey = (cellId, threadId) => `${cellId}\u0000${threadId}`;
520
+ function dropStatusFor(cellId) {
521
+ const prefix = `${cellId}\u0000`;
522
+ for (const key of [...threadStatusCache.keys()]) if (key.startsWith(prefix)) threadStatusCache.delete(key);
523
+ }
524
+
525
+ // `expectedThreadId`: chi attesta un riferimento chiede lo stato del thread
526
+ // che ha letto nel registro, non di quello che il registro nomina dopo.
527
+ async function threadStatus(cellId, expectedThreadId) {
528
+ const known = reg.get(cellId);
529
+ const threadId = expectedThreadId || (known && known.threadId);
502
530
  if (!threadId) return 'unknown';
531
+ const key = statusKey(cellId, threadId);
503
532
 
504
533
  const current = now();
505
- const cached = threadStatusCache.get(cellId);
534
+ const cached = threadStatusCache.get(key);
506
535
  if (cached && cached.expiresAt > current) return cached.value;
507
- if (cached) threadStatusCache.delete(cellId);
508
- if (threadStatusInFlight.has(cellId)) return threadStatusInFlight.get(cellId);
536
+ if (cached) threadStatusCache.delete(key);
537
+ if (threadStatusInFlight.has(key)) return threadStatusInFlight.get(key);
509
538
 
510
539
  const query = queryThreadStatusOnControlSocket({
511
540
  socketPath: cfg.liveBridgeSocketPath,
@@ -513,13 +542,17 @@ function createLiveBridge({
513
542
  timeoutMs: cfg.liveBridgeTimeoutMs,
514
543
  WebSocket,
515
544
  }).catch(() => 'unknown').then((value) => {
545
+ // A definite "gone" ends the entry: the reference stops resolving and the
546
+ // file does not keep a thread that no longer exists. Anything uncertain
547
+ // (error, timeout, system error) leaves it alone.
548
+ if (value === 'absent') reg.remove(cellId, threadId);
516
549
  // Anche unknown e' la risposta della query corrente: non riusiamo un
517
550
  // valore buono oltre la sua finestra, e il prossimo tick potra' misurare
518
551
  // di nuovo il nodo.
519
- threadStatusCache.set(cellId, { value, expiresAt: now() + threadStatusCacheMs });
552
+ threadStatusCache.set(key, { value, expiresAt: now() + threadStatusCacheMs });
520
553
  return value;
521
- }).finally(() => { threadStatusInFlight.delete(cellId); });
522
- threadStatusInFlight.set(cellId, query);
554
+ }).finally(() => { threadStatusInFlight.delete(key); });
555
+ threadStatusInFlight.set(key, query);
523
556
  return query;
524
557
  }
525
558
 
@@ -578,7 +611,11 @@ function createLiveBridge({
578
611
  // non è mai più assente: senza LIVE_PROMPT.md porta la sola intestazione
579
612
  // — e poiché il campo SOSTITUISCE le developer instructions della config
580
613
  // (see: the .or() in config/mod.rs), that cell no longer receives them.
581
- const intestazione = identityHeader(snap.hostCell, cell.tmuxSession);
614
+ // The reference the Live declares to NexusCrew tools: made before the
615
+ // thread exists (the header travels with thread/start) and registered only
616
+ // at commit, so a discarded start leaves nothing behind.
617
+ const liveRef = crypto.randomBytes(16).toString('hex');
618
+ const intestazione = identityHeader(snap.hostCell, cell.tmuxSession, liveRef);
582
619
  const developerInstructions = prompt.applied
583
620
  ? `${intestazione}\n\n${prompt.text}`
584
621
  : intestazione;
@@ -647,8 +684,10 @@ function createLiveBridge({
647
684
  socketPath: cfg.liveBridgeSocketPath,
648
685
  at: now(),
649
686
  };
650
- threadIdsByCell.set(snap.hostCell, started.threadId);
651
- threadStatusCache.delete(snap.hostCell);
687
+ reg.set(snap.hostCell, {
688
+ threadId: started.threadId, ref: liveRef, ...(cell.tmuxSession ? { tmuxSession: cell.tmuxSession } : {}),
689
+ });
690
+ dropStatusFor(snap.hostCell);
652
691
  // Il puntamento è visibile lato nostro — log con cella, thread e
653
692
  // prompt applicato. È il "dirottamento dichiarato" del contratto. Il
654
693
  // Campo SOSTITUISCE le developer instructions della config (la
@@ -658,8 +697,29 @@ function createLiveBridge({
658
697
  return out;
659
698
  }
660
699
 
700
+ // At startup: a registry entry whose thread the daemon reports as gone is
701
+ // dropped; one the daemon cannot vouch for (error, timeout, system error)
702
+ // stays, because absence is only ever concluded from a definite answer.
703
+ async function reconcile() {
704
+ const removed = [];
705
+ const kept = [];
706
+ for (const entry of reg.entries()) {
707
+ let value;
708
+ try {
709
+ value = await queryThreadStatusOnControlSocket({
710
+ socketPath: cfg.liveBridgeSocketPath, threadId: entry.threadId,
711
+ timeoutMs: cfg.liveBridgeTimeoutMs, WebSocket,
712
+ });
713
+ } catch (_) { value = 'unknown'; }
714
+ if (value === 'absent' && reg.remove(entry.cell, entry.threadId)) { dropStatusFor(entry.cell); removed.push(entry.cell); }
715
+ else kept.push(entry.cell);
716
+ }
717
+ return { removed, kept };
718
+ }
719
+
661
720
  return {
662
721
  resolveForLive,
722
+ reconcile,
663
723
  threadStatus,
664
724
  readCellPrompt: (tmuxSession) => { const { text, ...rest } = readCellPrompt(root, tmuxSession); return rest; },
665
725
  };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+ // lib/live-host/entry.js — la voce `<instanceId>:Live` della directory delle celle.
3
+ //
4
+ // La Live compare solo quando c'e' qualcosa di vivo da nominare: un thread
5
+ // nativo presente o attivo, oppure la modalita' tmux con l'ospite attivo. Non
6
+ // ha una sessione tmux propria e non nomina la cella ospite: chi legge la
7
+ // directory non deve poter confondere le due. canReceive resta false finche'
8
+ // non esiste una consegna dedicata alla Live; il motivo dice perche'.
9
+
10
+ const { isReservedLiveName } = require('./reserved.js');
11
+
12
+ const NATIVE_ALIVE = new Set(['present', 'active']);
13
+
14
+ async function buildLiveEntry({ store, bridge, fleetP, instanceId, now = Date.now }) {
15
+ try {
16
+ const snap = store.snapshot();
17
+ if (!snap || snap.hostCell == null || snap.ownerId) return null;
18
+ const fleet = await fleetP;
19
+ const statusFn = fleet && (typeof fleet.status === 'function' ? fleet.status : fleet.cellStatus);
20
+ if (!fleet || fleet.available !== true || typeof statusFn !== 'function') return null;
21
+ const status = await statusFn.call(fleet);
22
+ const cells = Array.isArray(status && status.cells) ? status.cells : [];
23
+ const host = cells.find((c) => c && c.cell === snap.hostCell && !isReservedLiveName(c.cell));
24
+ if (!host) return null;
25
+ // The Live works through its host cell (identity, tools, files): with the
26
+ // host off there is no Live to name, whatever the daemon still holds.
27
+ if (host.active !== true || host.tmux === false) return null;
28
+ const base = {
29
+ id: `${instanceId}:Live`,
30
+ instanceId,
31
+ cell: 'Live',
32
+ kind: 'live',
33
+ label: 'Live',
34
+ active: true,
35
+ canReceive: false,
36
+ lastSeen: now(),
37
+ };
38
+ if (String(host.engine || '').startsWith('codex-vl')) {
39
+ const thread = await bridge.threadStatus(snap.hostCell);
40
+ if (!NATIVE_ALIVE.has(thread)) return null;
41
+ return { ...base, mode: 'native', state: thread, reason: 'live-not-addressable' };
42
+ }
43
+ return { ...base, mode: 'tmux', state: 'tmux', reason: 'tmux-mode' };
44
+ } catch (_) {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ module.exports = { buildLiveEntry };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ // lib/live-host/registry.js — registro persistito dei thread Live.
3
+ //
4
+ // One entry per host cell: the thread the Live bridge started on the app-server
5
+ // and the reference that thread declares when it calls NexusCrew tools. It lives
6
+ // next to live-host.json so it survives a server restart: the thread itself
7
+ // lives in the daemon, and a restart must not make it look absent.
8
+ //
9
+ // The file is the truth. Every call reads it, so two instances over the same
10
+ // path agree; writes are synchronous read-modify-write (one event loop, no
11
+ // interleaving) and atomic on disk. A damaged file reads as empty and a damaged
12
+ // entry is skipped, never promoted.
13
+
14
+ const path = require('node:path');
15
+ const os = require('node:os');
16
+ const { readJsonSafe, atomicWriteJson } = require('../notify/persist.js');
17
+
18
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
19
+ const REF_RE = /^[a-f0-9]{32}$/;
20
+ const MAX_THREAD_ID = 128;
21
+ // One entry per host cell, and never more than this many: the oldest go first.
22
+ const MAX_ENTRIES = 64;
23
+
24
+ function liveThreadsPath(cfg = {}, home = (cfg.home || os.homedir())) {
25
+ if (cfg.liveThreadsPath) return cfg.liveThreadsPath;
26
+ if (cfg.tokenPath) return path.join(path.dirname(cfg.tokenPath), 'live-threads.json');
27
+ return path.join(home, '.nexuscrew', 'live-threads.json');
28
+ }
29
+
30
+ function validEntry(cell, raw) {
31
+ if (!CELL_ID_RE.test(String(cell || '')) || !raw || typeof raw !== 'object') return null;
32
+ if (typeof raw.threadId !== 'string' || !raw.threadId || raw.threadId.length > MAX_THREAD_ID) return null;
33
+ if (typeof raw.ref !== 'string' || !REF_RE.test(raw.ref)) return null;
34
+ const out = { threadId: raw.threadId, ref: raw.ref };
35
+ if (typeof raw.tmuxSession === 'string' && raw.tmuxSession) out.tmuxSession = raw.tmuxSession;
36
+ out.startedAt = Number.isFinite(raw.startedAt) ? raw.startedAt : 0;
37
+ return out;
38
+ }
39
+
40
+ function createLiveThreadRegistry({ filePath, now = () => Date.now() } = {}) {
41
+ if (!filePath || typeof filePath !== 'string') {
42
+ throw new Error('createLiveThreadRegistry: filePath richiesto');
43
+ }
44
+
45
+ function readAll() {
46
+ // Fail closed: a file that cannot be read safely (symlink, permissions wider
47
+ // than 0600) is treated as empty, never trusted. An empty registry means no
48
+ // thread is vouched for, which is the safe answer.
49
+ let raw;
50
+ try { raw = readJsonSafe(filePath); } catch (_) { return {}; }
51
+ const threads = raw && typeof raw.threads === 'object' && raw.threads && !Array.isArray(raw.threads)
52
+ ? raw.threads : {};
53
+ const out = {};
54
+ for (const [cell, value] of Object.entries(threads)) {
55
+ const entry = validEntry(cell, value);
56
+ if (entry) out[cell] = entry;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function writeAll(threads) {
62
+ atomicWriteJson(filePath, { version: 1, threads });
63
+ }
64
+
65
+ function get(cell) {
66
+ return readAll()[cell] || null;
67
+ }
68
+
69
+ function set(cell, entry) {
70
+ const next = validEntry(cell, { ...entry, startedAt: now() });
71
+ if (!next) throw new Error('voce del registro Live non valida');
72
+ const all = readAll();
73
+ all[cell] = next;
74
+ const cells = Object.keys(all);
75
+ if (cells.length > MAX_ENTRIES) {
76
+ cells.filter((name) => name !== cell)
77
+ .sort((a, b) => all[a].startedAt - all[b].startedAt)
78
+ .slice(0, cells.length - MAX_ENTRIES)
79
+ .forEach((name) => { delete all[name]; });
80
+ }
81
+ writeAll(all);
82
+ return next;
83
+ }
84
+
85
+ // With `expectedThreadId` the entry is removed only if it still names that
86
+ // thread: a slow read about an old thread must not remove a newer Live that
87
+ // replaced the entry in the meantime.
88
+ function remove(cell, expectedThreadId) {
89
+ const all = readAll();
90
+ if (!(cell in all)) return false;
91
+ if (expectedThreadId !== undefined && all[cell].threadId !== expectedThreadId) return false;
92
+ delete all[cell];
93
+ writeAll(all);
94
+ return true;
95
+ }
96
+
97
+ function entries() {
98
+ return Object.entries(readAll()).map(([cell, entry]) => ({ cell, ...entry }));
99
+ }
100
+
101
+ function findByRef(ref) {
102
+ if (typeof ref !== 'string' || !REF_RE.test(ref)) return null;
103
+ return entries().find((entry) => entry.ref === ref) || null;
104
+ }
105
+
106
+ return { get, set, remove, entries, findByRef, filePath };
107
+ }
108
+
109
+ createLiveThreadRegistry.MAX_ENTRIES = MAX_ENTRIES;
110
+
111
+ module.exports = { createLiveThreadRegistry, liveThreadsPath, REF_RE };
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+ // lib/live-host/reserved.js — il nome `Live` appartiene alla Live.
3
+ //
4
+ // Una cella con quel nome (in qualunque grafia) si scambierebbe con la voce di
5
+ // directory della Live. Il divieto vale alla scrittura e il nome e' filtrato
6
+ // in uscita, cosi' una definizione gia' presente non puo' sovrapporsi.
7
+
8
+ function isReservedLiveName(id) {
9
+ return typeof id === 'string' && id.toLowerCase() === 'live';
10
+ }
11
+
12
+ module.exports = { isReservedLiveName };
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