@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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-Cq8wINIe.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-fCIoVxeq.css">
14
+ <script type="module" crossorigin src="/assets/index-oUd5O8W8.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BC6vO7Zd.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.62"}
1
+ {"version":"0.9.64"}
@@ -4,6 +4,7 @@ const express = require('express');
4
4
  const { isValidSession } = require('../files/store.js');
5
5
  const { submitTextOk } = require('../tmux/actions.js');
6
6
  const { createIdentityBindingGuard } = require('../identity/binding-guard.js');
7
+ const { isReservedLiveName } = require('../live-host/reserved.js');
7
8
 
8
9
  const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
9
10
  const CELL_LABEL_MAX = 64;
@@ -29,6 +30,7 @@ function publicCells(status, instanceId, now = Date.now()) {
29
30
  const cells = [];
30
31
  for (const raw of status.cells) {
31
32
  if (!raw || !CELL_ID_RE.test(String(raw.cell || ''))
33
+ || isReservedLiveName(raw.cell)
32
34
  || !isValidSession(raw.tmuxSession) || seen.has(raw.cell)) continue;
33
35
  seen.add(raw.cell);
34
36
  // Un `tmux:false` esplicito prevale su active:true: la directory globale non
@@ -69,7 +71,15 @@ function validIdentity(value) {
69
71
  && isValidSession(value.tmuxSession);
70
72
  }
71
73
 
72
- function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now = () => Date.now(), diagnostics = null, identityMode = 'legacy' }) {
74
+ const LIVE_HEADER = 'x-nexuscrew-live-thread';
75
+
76
+ // `liveEntry({ instanceId, now })` -> the Live directory entry or null, and
77
+ // `liveAttest.verify({ ref, fromCell })` -> { ok, hostCell } are optional seams:
78
+ // without them the routes behave as before.
79
+ function cellsRoutes({
80
+ fleetP, instanceId, submit, readonly = () => false, now = () => Date.now(), diagnostics = null,
81
+ identityMode = 'legacy', liveEntry = null, liveAttest = null,
82
+ }) {
73
83
  const bindingGuard = createIdentityBindingGuard({
74
84
  fleetP, instanceId, now,
75
85
  sharedRequired: identityMode === 'authority',
@@ -98,11 +108,16 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
98
108
  try {
99
109
  const nodeId = instanceId();
100
110
  const st = await status();
111
+ // The Live is its own entry, never a cell: a failure here must not take
112
+ // the directory down, it only means the Live is not shown.
113
+ const live = liveEntry && NODE_ID_RE.test(String(nodeId || ''))
114
+ ? await Promise.resolve(liveEntry({ instanceId: nodeId, now })).catch(() => null) : null;
115
+ const cells = publicCells(st, nodeId, now());
101
116
  res.json({
102
117
  instanceId: NODE_ID_RE.test(String(nodeId || '')) ? nodeId : null,
103
118
  available: st.available === true,
104
119
  at: now(),
105
- cells: publicCells(st, nodeId, now()),
120
+ cells: live ? [...cells, live] : cells,
106
121
  });
107
122
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
108
123
  });
@@ -133,6 +148,11 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
133
148
  || !submitTextOk(body.message)) {
134
149
  return reject(400, 'messaggio cella non valido');
135
150
  }
151
+ // `Live` names the Live, which has no tmux session to paste into: never
152
+ // fall back to a cell of the same name or to its host.
153
+ if (isReservedLiveName(body.to.cell)) {
154
+ return reject(409, 'live-not-addressable: la Live non riceve messaggi da qui; nessuna consegna');
155
+ }
136
156
  const localId = instanceId();
137
157
  if (!NODE_ID_RE.test(String(localId || '')) || body.to.instanceId !== localId) {
138
158
  return reject(409, 'destinazione non appartiene a questo nodo');
@@ -165,7 +185,21 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
165
185
  && cell.tmuxSession === body.to.tmuxSession);
166
186
  if (!target) return reject(404, 'cella destinataria sconosciuta');
167
187
  if (!target.canReceive) return reject(409, 'cella destinataria non attiva');
168
- const label = `${body.from.cell}@${body.from.instanceId.slice(0, 8)}`;
188
+ let label = `${body.from.cell}@${body.from.instanceId.slice(0, 8)}`;
189
+ // The Live label is earned, not declared: only a local sender whose
190
+ // declared reference is registered, belongs to this host cell and has a
191
+ // live thread. Anything else travels as a plain cell message.
192
+ if (liveAttest && !visited.length) {
193
+ const rawRef = req.headers[LIVE_HEADER];
194
+ if (rawRef !== undefined) {
195
+ const verdict = await Promise.resolve(liveAttest.verify({
196
+ ref: Array.isArray(rawRef) ? rawRef[0] : rawRef, fromCell: body.from.cell,
197
+ })).catch(() => ({ ok: false }));
198
+ if (verdict && verdict.ok === true) {
199
+ label = `Live(via ${body.from.cell})@${body.from.instanceId.slice(0, 8)}`;
200
+ }
201
+ }
202
+ }
169
203
  // End on printable text even when the source message ends in a newline:
170
204
  // Pi may auto-submit a bracketed paste that ends with LF. NexusCrew owns
171
205
  // the single explicit Enter used by the transport.
@@ -1287,7 +1287,7 @@ async function dispatchNodes(rest, flags, opts = {}) {
1287
1287
  : String(flags.selected).split(',').map((value) => value.trim()).filter(Boolean);
1288
1288
  return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { visibility, selected } }).code };
1289
1289
  }
1290
- // Scope celle (NC-E): quali celle di QUESTO nodo il peer puo' vedere.
1290
+ // Scope celle: quali celle di QUESTO nodo il peer puo' vedere.
1291
1291
  // Diverso da `visibility`, che governa il transito e non l'accesso.
1292
1292
  if (sub === 'cells') {
1293
1293
  const arg = rest[3];
package/lib/config.js CHANGED
@@ -85,7 +85,7 @@ function baseDefaults() {
85
85
  // con `nexuscrew autoupdate on`, e resta spento finche' non lo si accende.
86
86
  autoUpdate: false,
87
87
  sessionPresets: {},
88
- // Ponte Live (fetta 3, rev5): isolabile — a false il ponte non si
88
+ // Ponte Live (fetta 3): isolabile — a false il ponte non si
89
89
  // connette mai e ogni avvio di Live resta sul comportamento standard.
90
90
  liveBridgeEnabled: true,
91
91
  // Socket di controllo dell'app-server (misurato 2026-08-15:
@@ -44,6 +44,7 @@ const {
44
44
  const { probeModel, probeCustomEndpoint } = require('./model-probe.js');
45
45
  const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
46
46
  const { validEnvKey } = require('./env-key.js');
47
+ const { isReservedLiveName } = require('../live-host/reserved.js');
47
48
  const { setCredential, removeCredential } = require('./credentials.js');
48
49
  const { createLaunchBroker } = require('./launch-broker.js');
49
50
  const { createLeaseManager } = require('./cell-lease-server.js');
@@ -1057,6 +1058,7 @@ async function createBuiltinFleet(cfg = {}) {
1057
1058
  async function defineCell(def) {
1058
1059
  if (readonly()) throw httpError(403, 'READONLY: define-cell bloccato');
1059
1060
  if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione cell mancante');
1061
+ if (isReservedLiveName(def.id)) throw httpError(400, 'id riservato: Live indica la Live, non una cella');
1060
1062
  if (def.id != null && findCell(reloadDefs(), def.id)) throw httpError(400, `cell esiste già: ${def.id}`);
1061
1063
  // cwd portabile fail-closed: risolve cwd/cwdRel in coppia coerente PRIMA di
1062
1064
  // scrivere (nessuna scrittura parziale su input non portabile).
@@ -1338,6 +1340,7 @@ async function createBuiltinFleet(cfg = {}) {
1338
1340
  }
1339
1341
  let id = typeof b.id === 'string' && b.id.trim() ? b.id.trim() : deriveCellId(tmuxSession, defs.cells);
1340
1342
  if (!CELL_ID_RE.test(id)) throw httpError(400, 'id non valido (A-Za-z0-9._- max 32)');
1343
+ if (isReservedLiveName(id)) throw httpError(400, 'id riservato: Live indica la Live, non una cella');
1341
1344
  if (defs.cells.some((c) => c.id === id)) throw httpError(409, `id cella già usato: ${id}`);
1342
1345
  const cwd = typeof b.cwd === 'string' && b.cwd.trim() ? b.cwd.trim() : home;
1343
1346
  const cellDef = { id, cwd, engine: engineId, boot: b.boot === true, tmuxSession };
@@ -2,7 +2,7 @@
2
2
 
3
3
  // LeaseManager lato server per il lease del supervisore di una cella Live host.
4
4
  //
5
- // Fetta 2b (contratto rev1): l'autenticazione del reconnect e' un proof HMAC
5
+ // Fetta 2b: l'autenticazione del reconnect e' un proof HMAC
6
6
  // firmato con un verifier PER-INSTALLAZIONE — la capability statica condivisa
7
7
  // Della 2a e' REVOCATA, non affiancata (/). Solo il server conosce il
8
8
  // Segreto; il supervisore presenta un proof firmato con claims ed expiry (
@@ -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 };