@mmmbuto/nexuscrew 0.8.57 → 0.9.0

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 (49) hide show
  1. package/CHANGELOG.md +164 -2
  2. package/README.md +1 -0
  3. package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
  4. package/frontend/dist/assets/index-zjL6kZ7J.js +93 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/dist/version.json +1 -1
  7. package/lib/audio/adapters.js +50 -6
  8. package/lib/cli/commands.js +40 -2
  9. package/lib/cli/doctor.js +95 -12
  10. package/lib/cli/init.js +25 -3
  11. package/lib/cli/path.js +43 -10
  12. package/lib/cli/pidfile.js +23 -2
  13. package/lib/config.js +15 -0
  14. package/lib/fleet/builtin.js +161 -19
  15. package/lib/fleet/catalogs/opencode-go.json +328 -0
  16. package/lib/fleet/cell-exec.js +87 -9
  17. package/lib/fleet/cell-lease-server.js +719 -0
  18. package/lib/fleet/cell-lease.js +112 -0
  19. package/lib/fleet/definitions.js +101 -7
  20. package/lib/fleet/launch-broker.js +115 -3
  21. package/lib/fleet/lease-client.js +191 -0
  22. package/lib/fleet/lease-routes.js +92 -0
  23. package/lib/fleet/lease-verifier.js +230 -0
  24. package/lib/fleet/managed.js +444 -55
  25. package/lib/fleet/prompt-delivery.js +50 -2
  26. package/lib/fleet/provider.js +1 -1
  27. package/lib/fleet/runtime.js +53 -6
  28. package/lib/live-host/bridge.js +369 -0
  29. package/lib/live-host/routes.js +184 -0
  30. package/lib/live-host/store.js +96 -0
  31. package/lib/mcp/tools.js +51 -0
  32. package/lib/nodes/commands.js +9 -2
  33. package/lib/nodes/store.js +14 -0
  34. package/lib/nodes/tunnel.js +4 -1
  35. package/lib/proxy/federation.js +106 -9
  36. package/lib/proxy/node-proxy.js +33 -0
  37. package/lib/proxy/panel-auth.js +307 -0
  38. package/lib/proxy/panel-proxy.js +305 -0
  39. package/lib/server.js +127 -4
  40. package/package.json +1 -1
  41. package/skills/alibaba-token-media/SKILL.md +19 -0
  42. package/skills/crew/SKILL.md +15 -0
  43. package/skills/fill-forms/SKILL.md +23 -0
  44. package/skills/mail-assistant/SKILL.md +15 -0
  45. package/skills/memory/SKILL.md +15 -0
  46. package/skills/nexuscrew-agent/SKILL.md +18 -0
  47. package/skills/vl-msa/SKILL.md +15 -0
  48. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
  49. package/frontend/dist/assets/index-_c-1_3iR.js +0 -93
@@ -90,7 +90,7 @@ function tailOf(text) {
90
90
 
91
91
  // classifyPane(captured, client) -> PANE_STATES enum. Pura, senza dipendenze:
92
92
  // il testo resta dentro la funzione, fuori esce solo lo stato bounded.
93
- // client: 'kimi' | 'claude' (gli altri non passano mai di qui: 'unknown').
93
+ // client: 'kimi' | 'claude' | 'vl' (gli altri non passano mai di qui: 'unknown').
94
94
  function classifyPane(captured, client) {
95
95
  const text = typeof captured === 'string' ? captured : '';
96
96
  if (!text.trim()) return 'unknown';
@@ -112,9 +112,57 @@ function classifyPane(captured, client) {
112
112
  // not-ready (gia' valutati sopra: il cursore ❯ dei dialoghi non inganna).
113
113
  return /^\s*❯/m.test(tail) ? 'ready' : 'unknown';
114
114
  }
115
+ if (client === 'vl') {
116
+ // READY positivo VL (TUI Vivling): la riga di stato in coda porta il
117
+ // marcatore stabile prefisso `vivling` + stato `[o]` e la coda
118
+ // `esc quit · ^y yield`. Misurato 2026-08-14 su due backend
119
+ // (zai-a-coding, opencode-go): il marcatore e' stabile a prescindere dal
120
+ // backend, dal writer-id (e<numero>) e dal contatore (↑<numero>).
121
+ //
122
+ // NON si classifica 'busy' dal contenuto: su alcuni backend il ragionamento
123
+ // non viene renderizzato e il pane resta IDENTICO mentre la cella lavora
124
+ // (gli stati [?] e [<<] esistono ma non sono affidabili per il busy). Il
125
+ // busy di vl si rileva con la sonda esterna (CPU del demone, connessione
126
+ // :443, figli), non di qui; qui si dice solo "pronta" ([o]) o "non pronta".
127
+ // Il titolo pane NON reca lo spinner braille (vl.bin non contiene "tmux"):
128
+ // il ready si prende dalla riga di stato, MAI dal titolo (che resta idle).
129
+ return /vivling\s+\[o\].*esc quit · \^y yield/.test(tail) ? 'ready' : 'unknown';
130
+ }
115
131
  return 'unknown';
116
132
  }
117
133
 
134
+ // vlPaneReadiness(tmuxBin, target, opts) -> { ready, degraded }
135
+ // DEC1: content-readiness di una cella vl via pane — il marcatore [o] + coda
136
+ // `esc quit · ^y yield` riconosciuto da classifyPane(text,'vl'). E' il PUNTO DI
137
+ // INNESTO della readiness vl: quando arrivera' l'adapter di runtime, la
138
+ // readiness si misurera' sul socket di controllo (vl-core/src/transport.rs) e
139
+ // non sul pane — si sostituisce questa funzione, non si cercano capture-pane
140
+ // sparsi nel codice. Un solo chiamante (runtime.js, ramo vl post-liveness).
141
+ //
142
+ // MAI fail-closed su un'euristica di testo. Se entro il timeout il marcatore
143
+ // non compare (backend che cambia la riga di stato, pane non ancora stabile),
144
+ // DEGRADA: ritorna {ready:true, degraded:true} e procede come se fosse pronta.
145
+ // Una cella che oggi parte deve partire anche domani: il peggio ammesso e'
146
+ // tornare al comportamento di oggi (avvio sulla sola liveness), MAI "non parte
147
+ // piu'". Il chiamante lascia traccia del degrado (readinessDegraded nel risultato).
148
+ async function vlPaneReadiness(tmuxBin, target, {
149
+ env, timeoutMs = 12000, pollMs = 400, captureImpl, sleepImpl, nowImpl,
150
+ } = {}) {
151
+ const sleepFn = sleepImpl || sleep;
152
+ const now = nowImpl || Date.now;
153
+ const capture = captureImpl || (async () => {
154
+ const r = await tmuxExec(tmuxBin, ['capture-pane', '-p', '-t', target], { env, timeoutMs: 2000 });
155
+ return r.err ? null : r.stdout;
156
+ });
157
+ const deadline = now() + Math.max(0, timeoutMs);
158
+ for (;;) {
159
+ const text = await capture();
160
+ if (text !== null && classifyPane(text, 'vl') === 'ready') return { ready: true, degraded: false };
161
+ if (now() >= deadline) return { ready: true, degraded: true };
162
+ await sleepFn(pollMs);
163
+ }
164
+ }
165
+
118
166
  function clampInt(value, dflt, min, max) {
119
167
  const n = Number(value);
120
168
  if (!Number.isFinite(n)) return dflt;
@@ -271,5 +319,5 @@ function actionRequiredFor(client, provider, delivery) {
271
319
 
272
320
  module.exports = {
273
321
  PANE_STATES, DELIVERY_STATES, ACTION_CODES, RECOVERY_SLUGS,
274
- classifyPane, deliverBootstrapPrompt, waitDeliveryReport, actionRequiredFor,
322
+ classifyPane, deliverBootstrapPrompt, waitDeliveryReport, actionRequiredFor, vlPaneReadiness,
275
323
  };
@@ -21,7 +21,7 @@ async function selectProvider(cfg = {}) {
21
21
  if (cfg.fleetSeam) return { mode: 'seam', reason: 'fleet iniettata (seam di test)', fleet: cfg.fleetSeam };
22
22
  if (cfg.fleetEnabled === false) return disabled('fleet disabilitata (fleetEnabled=false)');
23
23
  if (cfg.builtinEnabled === false) return disabled('fleet builtin disabilitata (builtinEnabled=false)');
24
- const fleet = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'NexusCrew builtin fleet' });
24
+ const fleet = await createBuiltinFleet({ ...cfg, cellLeaseEnabled: cfg.cellLeaseEnabled, fleetProviderReason: 'NexusCrew builtin fleet' });
25
25
  if (!fleet.available) return disabled(fleet.reason || 'fleet.json mancante o invalido (fail-closed)');
26
26
  return { mode: 'builtin', reason: 'NexusCrew builtin fleet', fleet };
27
27
  }
@@ -25,7 +25,7 @@ const {
25
25
  waitAlive, waitStablePane, injectPrompt,
26
26
  redactSecrets, sanitizeEarlyDiagnostic,
27
27
  } = require('./launch.js');
28
- const { waitDeliveryReport, actionRequiredFor } = require('./prompt-delivery.js');
28
+ const { waitDeliveryReport, actionRequiredFor, vlPaneReadiness } = require('./prompt-delivery.js');
29
29
 
30
30
  // TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
31
31
  const STATUS_TTL_MS = 2000;
@@ -41,7 +41,7 @@ function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) ||
41
41
  // e' tornato unavailable su garbage).
42
42
  // ---------------------------------------------------------------------------
43
43
  function createBuiltinRuntime(ctx) {
44
- const { cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot, ensureProtection } = ctx;
44
+ const { cfg, home, defsPath, tmuxBin, readonly, launchBroker, leaseManager, boot, ensureProtection } = ctx;
45
45
  let cache = { at: 0, defs: boot, sessions: new Set() };
46
46
 
47
47
  function reloadDefs() {
@@ -73,7 +73,15 @@ function createBuiltinRuntime(ctx) {
73
73
  // La directory cella e il trasporto MCP dipendono soltanto da definizioni e
74
74
  // tmux. Tenerla separata dai cataloghi modello evita che un binario esterno
75
75
  // lento trasformi `/api/cells` in un falso guasto della flotta.
76
- async function cellStatus() {
76
+ // `includeCwd` di default NEGATO. La cwd reale e' un path assoluto della
77
+ // macchina, e questa vista alimenta anche `GET /fleet/status`, che e' nella
78
+ // allowlist federata con inoltro trasparente: senza questo default, la
79
+ // directory di ogni cella uscirebbe verso ogni peer. Serve a un consumatore
80
+ // solo — il ponte Live, che la chiede esplicitamente e in-processo — quindi
81
+ // e' lui a doverla chiedere, non tutti gli altri a doversene ricordare.
82
+ // Il backup vieta gia' le cwd assolute per la stessa ragione: sono specifiche
83
+ // del dispositivo. Rilievo di un audit indipendente.
84
+ async function cellStatus({ includeCwd = false } = {}) {
77
85
  if (Date.now() - cache.at > STATUS_TTL_MS) {
78
86
  reloadDefs(); // pick-up di edit esterne/file
79
87
  const sessions = await refreshSessions();
@@ -92,16 +100,37 @@ function createBuiltinRuntime(ctx) {
92
100
  const effectivePolicy = ['pi', 'shell'].includes(engineDef?.managed?.client)
93
101
  ? 'standard'
94
102
  : (remembered || engineDefault || '');
103
+ // D8: panelUrl per-cella vince su quello precompilato dall'engine (es.
104
+ // desktop.local), che a sua volta e' il default. Stessa forma di
105
+ // engineDef gia' usata sopra per la permission policy: un valore
106
+ // presente qui e' gia' passato da validPanelUrl a monte (parseEngine/
107
+ // parseCell), quindi si copia, non si ri-valida.
108
+ const panelUrl = c.panelUrl || engineDef?.panelUrl || '';
95
109
  return {
96
110
  // `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
97
111
  // leggibile e viaggia accanto, senza mai sostituirlo.
98
112
  cell: c.id, label: c.label || '', tmuxSession: c.tmuxSession, engine: c.engine,
113
+ // cwd reale della cella (resolveCwd), per il ponte Live (fetta 3): la
114
+ // thread ponte parte con la directory della cella designata. Null se
115
+ // la definizione non risolve: il ponte lo dichiara, non lo indovina.
116
+ // Presente SOLO su richiesta esplicita: vedi il commento su cellStatus.
117
+ ...(includeCwd ? { cwd: resolveCwd(c.cwd, home) || null } : {}),
99
118
  model: c.model || '', models: { ...(c.models || {}) },
100
119
  permissionPolicy: effectivePolicy,
101
120
  permissionPolicies: { ...(c.permissionPolicies || {}) },
102
121
  active: alive, boot: c.boot, tmux: alive,
103
122
  supervised: true, keepalive: true,
123
+ // Seam lease↔designazione (2026-08-15): il facade possiede leaseManager
124
+ // nel ctx e qui lo esprime. `active` resta la verita' tmux (con
125
+ // remain-on-exit la sessione sopravvive alla morte del supervisore);
126
+ // `lease` e' la verita' di supervisione: live|grace|expired|none, o
127
+ // 'unavailable' senza leaseManager — il fail-open dichiarato delle
128
+ // route live-host, mai un valore che finga una verifica avvenuta.
129
+ lease: leaseManager && typeof leaseManager.status === 'function'
130
+ ? ((leaseManager.status(c.id) || {}).state || 'none')
131
+ : 'unavailable',
104
132
  rc: '', key: '', degraded: false, // supervisor vivo <=> sessione tmux viva
133
+ panelUrl,
105
134
  };
106
135
  });
107
136
  return {
@@ -113,8 +142,8 @@ function createBuiltinRuntime(ctx) {
113
142
  };
114
143
  }
115
144
 
116
- async function status() {
117
- const base = await cellStatus();
145
+ async function status(opts = {}) {
146
+ const base = await cellStatus(opts);
118
147
  const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
119
148
  const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
120
149
  // Le discovery esterne hanno budget propri. Avviarle in parallelo mantiene
@@ -189,7 +218,11 @@ function createBuiltinRuntime(ctx) {
189
218
  const child = composeClientInvocation(launchEngine, cell);
190
219
  let ticket;
191
220
  try {
192
- ticket = await launchBroker.issue({
221
+ let leaseInfo = null;
222
+ if (leaseManager) {
223
+ try { leaseInfo = await leaseManager.track(cell.id); } catch (_) { leaseInfo = null; }
224
+ }
225
+ ticket = await launchBroker.issue({
193
226
  command: child.command,
194
227
  args: child.args,
195
228
  env: {
@@ -197,6 +230,7 @@ function createBuiltinRuntime(ctx) {
197
230
  ...launchEngine.env,
198
231
  NEXUSCREW_MCP_SESSION: cell.tmuxSession,
199
232
  },
233
+ ...(leaseInfo ? { lease: { cellId: cell.id, launchEpoch: leaseInfo.launchEpoch, stablePath: leaseInfo.stablePath } } : {}),
200
234
  supervise: {
201
235
  enabled: !launchEngine.shellOneShot,
202
236
  initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
@@ -394,6 +428,18 @@ function createBuiltinRuntime(ctx) {
394
428
  ['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
395
429
  }
396
430
 
431
+ // DEC1: content-readiness vl (marcatore [o] nel pane). MAI fail-closed: se
432
+ // entro il timeout il marcatore non compare DEGRADA e procede come se pronta
433
+ // (una cella che parte oggi deve partire anche domani), lasciando traccia
434
+ // nel risultato. vlPaneReadiness e' il punto di innesto sostituibile quando
435
+ // la readiness si misurera' sul socket (adapter), non sul pane. Solo vl: gli
436
+ // altri client non hanno content-readiness da pane.
437
+ let readinessDegraded = false;
438
+ if (engine.managed && engine.managed.client === 'vl' && readiness.target) {
439
+ const vlReady = await vlPaneReadiness(tmuxBin, readiness.target, { env: minimalEnv() });
440
+ if (vlReady.degraded) readinessDegraded = true;
441
+ }
442
+
397
443
  // Il command Shell e' partito ed e' ancora vivo dopo la finestra di
398
444
  // readiness: la cella deve risultare attiva (per CLI interattive come agy),
399
445
  // poi tornera' inattiva quando il processo terminera' naturalmente.
@@ -450,6 +496,7 @@ function createBuiltinRuntime(ctx) {
450
496
  return {
451
497
  ok: true, cell: cellId, session: cell.tmuxSession, prompt,
452
498
  ...(actionRequired ? { actionRequired } : {}),
499
+ ...(readinessDegraded ? { readinessDegraded: true } : {}),
453
500
  };
454
501
  }
455
502
 
@@ -0,0 +1,369 @@
1
+ 'use strict';
2
+ // lib/live-host/bridge.js — il ponte Live (fetta 3, contratto rev5 + rev4 LC).
3
+ //
4
+ // Esposizione: POST /api/live-host/bridge (montata in routes.js, stesso
5
+ // requireToken e stessa policy local-only del proxy: LOCAL_ONLY_PREFIXES copre
6
+ // l'intero prefisso /api/live-host, quindi nessun peer federato può innescare
7
+ // il ponte). La chiamata rappresenta l'avvio di una Live sul nodo: chi la fa è
8
+ // il lato app-server della stessa feature (non costruito qui), e il risultato
9
+ // dice su che cosa quella Live va a operare.
10
+ //
11
+ // Invarianti (contratto fetta 3):
12
+ // - MC1: la designazione si legge con UNA GET su loopback verso
13
+ // /api/live-host, autenticata col token del nodo. Nessun accesso diretto
14
+ // allo store: la route è l'unica verità, `eligible` non si ricalcola.
15
+ // - MC1.5 / JC3.3: nessuna attesa introdotta. Ogni fase ha il limite
16
+ // dichiarato cfg.liveBridgeTimeoutMs; oltre quello, o su qualunque
17
+ // fallimento, la risposta è `none` col motivo: la Live parte senza
18
+ // puntamento, comportamento standard. Un `none` non è un errore HTTP.
19
+ // - MC2: il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
20
+ // cella) viaggia su developerInstructions di thread/start — quindi
21
+ // SOSTITUISCE l'iniezione globale per quella Live (rev4 LC2 emendata da
22
+ // rev5 MC2). Se manca non si passa nulla e l'app-server applica la sua
23
+ // catena globale/default: nessun gradino è un errore (MC2.4).
24
+ // - MC3: il ponte crea le proprie conversazioni con thread/start e non
25
+ // tocca MAI la thread di una TUI — né turn/start né thread/resume: chi
26
+ // guarda i metodi visti dal server deve vedere solo initialize,
27
+ // initialized e thread/start. Per questo l'aggancio funziona anche su una
28
+ // cella che sta già processando un turno: conversazioni separate, nessuna
29
+ // interruzione (rev1 HC2/rev2 JC4).
30
+ // - MC3.3: la connessione al socket di controllo è ON-DEMAND (connect →
31
+ // handshake → thread/start → close), mai permanente: la fuga notifiche
32
+ // notata in rev5 riguarda i client permanenti.
33
+ // - MC3.4: il ponte opera SOLO sulla cella designata — non accetta target
34
+ // dal chiamante, la designazione è la condizione (LC3).
35
+ // - MC0: isolabile — cfg.liveBridgeEnabled=false e il ponte non si connette
36
+ // mai, non fa GET, risponde `none` senza toccare nulla.
37
+ //
38
+ // Protocollo del socket di controllo (misurato sul runtime 2026-08-15):
39
+ // WebSocket (text frame) sopra unix socket, JSON-RPC. Handshake: request
40
+ // `initialize` → response {userAgent, codexHome} → notifica `initialized`
41
+ // (senza params). Poi `thread/start` {cwd, developerInstructions?} → response
42
+ // {thread:{id}, cwd}. Il socket è 0600 dell'utente: il confine è quello
43
+ // (MC1.3), non c'è autenticazione applicativa.
44
+
45
+ const fs = require('node:fs');
46
+ const path = require('node:path');
47
+ const os = require('node:os');
48
+
49
+ // ws+unix:// è supportato nativamente da ws >= 8 (isIpcUrl): il path prima dei
50
+ // `:` è il socket, dopo è la resource. Iniettabile per i test.
51
+ let WebSocketImpl = null;
52
+ function defaultWebSocket() {
53
+ if (!WebSocketImpl) WebSocketImpl = require('ws');
54
+ return WebSocketImpl;
55
+ }
56
+
57
+ const CLIENT_NAME = 'nexuscrew-live-bridge';
58
+ // Finestra concessa alla risposta di una thread/start ancora in volo quando il
59
+ // ponte ha gia' dichiarato l'esito: se arriva, la thread e' nata davvero e la
60
+ // chiudiamo. Breve di proposito — non allunga la risposta a chi ha chiesto il
61
+ // ponte, che e' gia' stata data.
62
+ const ORPHAN_GRACE_MS = 1500;
63
+
64
+ // —— Prompt per-cella (rev4 LC2, nome fisso confermato da Dev 2026-08-15) ——
65
+ // Collocazione: filesRoot/cloud-<Cella>/LIVE_PROMPT.md. Tre esiti DISTINTI,
66
+ // perché «file non c'è» e «c'è ma non si può leggere» portano chi indaga in
67
+ // posti diversi:
68
+ // applied:true → il testo va su developerInstructions
69
+ // applied:false, reason missing → ENOENT: assenza legittima (LC2.3), si
70
+ // procede senza, l'app-server applica il
71
+ // proprio gradino globale
72
+ // applied:false, reason unreadable|empty → presente ma inutilizzabile: va
73
+ // dichiarato, mai silenziato
74
+ function readCellPrompt(filesRoot, cellId) {
75
+ // Il canonico per-cella vive in filesRoot/cloud-<Cella>/. L'id cella arriva
76
+ // sia senza prefisso (fleet id) sia con (nome sessione tmux): si normalizza
77
+ // qui, una volta sola.
78
+ const dirName = String(cellId).startsWith('cloud-') ? String(cellId) : `cloud-${cellId}`;
79
+ const file = path.join(filesRoot, dirName, 'LIVE_PROMPT.md');
80
+ let raw;
81
+ try {
82
+ raw = fs.readFileSync(file, 'utf8');
83
+ } catch (e) {
84
+ if (e && e.code === 'ENOENT') return { applied: false, reason: 'missing' };
85
+ return { applied: false, reason: 'unreadable', detail: String((e && e.code) || e) };
86
+ }
87
+ const text = String(raw).trim();
88
+ if (!text) return { applied: false, reason: 'empty' };
89
+ return { applied: true, source: 'LIVE_PROMPT.md', text };
90
+ }
91
+
92
+ // —— Client on-demand del socket di controllo (sezione protocollo sopra) ——
93
+ // Una sola richiesta per connessione: aperta, handshake, thread/start, chiusa.
94
+ // Le eventuali notifiche broadcast che arrivano nel frattempo vengono ignorate
95
+ // e la finestra resta minima.
96
+ function startThreadOnControlSocket({
97
+ socketPath, cwd, developerInstructions, timeoutMs,
98
+ WebSocket = defaultWebSocket(), now = () => Date.now(), log = () => {},
99
+ }) {
100
+ return new Promise((resolve, reject) => {
101
+ const deadline = now() + timeoutMs;
102
+ let settled = false;
103
+ let nextId = 0;
104
+ const pending = new Map();
105
+ let ws;
106
+ // Id della richiesta thread/start: serve a riconoscerne la risposta anche
107
+ // quando arriva dopo che abbiamo gia' risolto, per non lasciare orfana una
108
+ // thread che nel frattempo e' nata davvero.
109
+ let startRequestId = null;
110
+ let orphanTimer = null;
111
+
112
+ // Chiusura del socket, separata dalla risoluzione della promessa: chi ha
113
+ // chiesto il ponte riceve subito la risposta, la pulizia puo' prendersi
114
+ // qualche istante in piu'.
115
+ const chiudi = () => {
116
+ try {
117
+ if (!ws) return;
118
+ // Se non e' OPEN, `close()` non fa nulla e la connessione resta
119
+ // appesa: su un socket in CONNECTING l'evento 'open' scatterebbe DOPO,
120
+ // e senza la guardia in cima al gestore aprirebbe una thread su un
121
+ // ponte gia' risolto. `terminate()` la chiude davvero.
122
+ if (ws.readyState === WebSocket.OPEN) ws.close(1000); else ws.terminate();
123
+ } catch (_) { /* best effort */ }
124
+ };
125
+
126
+ const done = (err, value) => {
127
+ if (settled) return;
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ // Se una thread/start e' ancora in volo, la thread potrebbe nascere UN
131
+ // ISTANTE DOPO che abbiamo dichiarato il fallimento: chiudere ora la
132
+ // lascerebbe orfana, viva e senza nessuno che la usi. Diamo una finestra
133
+ // breve per riceverne la risposta e chiuderla noi. E' best effort, ma la
134
+ // differenza fra "nessuno la chiude" e "quasi sempre la chiudiamo" e'
135
+ // esattamente il difetto.
136
+ if (startRequestId !== null && pending.has(startRequestId)) {
137
+ orphanTimer = setTimeout(chiudi, ORPHAN_GRACE_MS);
138
+ } else {
139
+ chiudi();
140
+ }
141
+ if (err) reject(err); else resolve(value);
142
+ };
143
+ const timer = setTimeout(() => {
144
+ done(Object.assign(new Error('control socket timeout'), { code: 'ETIMEOUT' }));
145
+ }, timeoutMs);
146
+
147
+ try {
148
+ ws = new WebSocket(`ws+unix://${socketPath}:/`, { handshakeTimeout: timeoutMs });
149
+ } catch (e) {
150
+ done(Object.assign(new Error(`control socket: ${e.message}`), { code: 'ESOCKET' }));
151
+ return;
152
+ }
153
+
154
+ const send = (obj) => ws.send(JSON.stringify(obj));
155
+ const request = (method, params) => new Promise((res, rej) => {
156
+ const id = ++nextId;
157
+ pending.set(id, { res, rej });
158
+ send({ jsonrpc: '2.0', id, method, params });
159
+ });
160
+ // Come `request`, ma comunica l'id al chiamante prima di attendere: serve a
161
+ // riconoscere la risposta tardiva di thread/start.
162
+ const requestTracked = (method, params, onId) => new Promise((res, rej) => {
163
+ const id = ++nextId;
164
+ pending.set(id, { res, rej });
165
+ onId(id);
166
+ send({ jsonrpc: '2.0', id, method, params });
167
+ });
168
+
169
+ ws.on('open', async () => {
170
+ // Il ponte puo' essere gia' stato risolto (timeout durante l'handshake):
171
+ // procedere qui aprirebbe una thread che nessuno aspetta piu'.
172
+ if (settled) { chiudi(); return; }
173
+ try {
174
+ await request('initialize', {
175
+ clientInfo: { name: CLIENT_NAME, title: 'NexusCrew Live Bridge', version: bridgeVersion() },
176
+ capabilities: { experimentalApi: true },
177
+ });
178
+ send({ jsonrpc: '2.0', method: 'initialized' }); // notifica, senza params
179
+ const params = { cwd };
180
+ if (developerInstructions) params.developerInstructions = developerInstructions;
181
+ const out = await requestTracked('thread/start', params, (id) => { startRequestId = id; });
182
+ const threadId = out && out.thread && out.thread.id;
183
+ if (!threadId) {
184
+ done(Object.assign(new Error('thread/start senza thread.id'), { code: 'EPROTO' }));
185
+ return;
186
+ }
187
+ done(null, { threadId, cwd: out.cwd || cwd });
188
+ } catch (e) {
189
+ done(e);
190
+ }
191
+ });
192
+
193
+ ws.on('message', (data) => {
194
+ let msg;
195
+ try { msg = JSON.parse(String(data)); } catch (_) { return; /* frame non JSON: ignorato */ }
196
+ // Risposta tardiva a thread/start su un ponte gia' risolto: la thread
197
+ // ESISTE. Chiuderla e' l'unica cosa che la distingue da un'orfana.
198
+ if (settled && msg && msg.id != null && msg.id === startRequestId) {
199
+ pending.delete(msg.id);
200
+ const threadId = msg.result && msg.result.thread && msg.result.thread.id;
201
+ if (threadId) {
202
+ try { send({ jsonrpc: '2.0', id: ++nextId, method: 'thread/stop', params: { threadId } }); } catch (_) { /* best effort */ }
203
+ log({ event: 'live-bridge', outcome: 'orphan-thread-stopped', threadId });
204
+ }
205
+ if (orphanTimer) { clearTimeout(orphanTimer); orphanTimer = null; }
206
+ setTimeout(chiudi, 50);
207
+ return;
208
+ }
209
+ if (msg && msg.id != null && pending.has(msg.id)) {
210
+ const waiter = pending.get(msg.id);
211
+ pending.delete(msg.id);
212
+ if (msg.error) waiter.rej(Object.assign(new Error(msg.error.message || 'jsonrpc error'), { code: 'ERPC', detail: msg.error }));
213
+ else waiter.res(msg.result);
214
+ return;
215
+ }
216
+ // Notifiche broadcast e risposte non attese: ignorate (connessione
217
+ // on-demand, la finestra di esposizione alla fuga MC3.3 è minima).
218
+ });
219
+
220
+ ws.on('error', (e) => done(Object.assign(new Error(`control socket: ${e.message}`), { code: 'ESOCKET' })));
221
+ ws.on('close', () => {
222
+ if (!settled) done(Object.assign(new Error('control socket chiuso prima della risposta'), { code: 'ESOCKET' }));
223
+ });
224
+ void deadline; // il timer copre l'intera finestra, il deadline è informativo
225
+ });
226
+ }
227
+
228
+ // —— Il ponte ——
229
+ function createLiveBridge({
230
+ cfg,
231
+ fleetP,
232
+ tokenGet,
233
+ filesRoot,
234
+ fetchImpl = globalThis.fetch,
235
+ WebSocket,
236
+ now = () => Date.now(),
237
+ log = () => {},
238
+ }) {
239
+ const root = filesRoot || cfg.filesRoot || path.join(os.homedir(), 'NexusFiles');
240
+
241
+ const none = (reason, extra) => ({ mode: 'none', reason, ...(extra || {}), at: now() });
242
+
243
+ // MC1: la designazione si legge dalla ROUTE, con il token del nodo, entro
244
+ // il limite dichiarato. retry no, cache no: una lettura per avvio Live.
245
+ async function readDesignation() {
246
+ const ctrl = new AbortController();
247
+ const t = setTimeout(() => ctrl.abort(), cfg.liveBridgeTimeoutMs);
248
+ try {
249
+ const res = await fetchImpl(`http://127.0.0.1:${cfg.port}/api/live-host`, {
250
+ headers: { authorization: `Bearer ${tokenGet()}` },
251
+ signal: ctrl.signal,
252
+ });
253
+ if (res.status !== 200) {
254
+ const e = new Error(`live-host HTTP ${res.status}`);
255
+ e.code = 'EHTTP';
256
+ throw e;
257
+ }
258
+ return await res.json();
259
+ } finally {
260
+ clearTimeout(t);
261
+ }
262
+ }
263
+
264
+ async function rosterCell(cellId) {
265
+ const fleet = await fleetP;
266
+ if (!fleet || fleet.available !== true) return null;
267
+ const statusFn = fleet && (typeof fleet.status === 'function' ? fleet.status : fleet.cellStatus);
268
+ if (typeof statusFn !== 'function') return null;
269
+ // La cwd va chiesta: la vista pubblica non la porta piu', perche' finiva
270
+ // anche nella risposta federata di /fleet/status.
271
+ const st = await statusFn.call(fleet, { includeCwd: true });
272
+ const cells = Array.isArray(st && st.cells) ? st.cells : [];
273
+ return cells.find((c) => c && c.cell === cellId) || null;
274
+ }
275
+
276
+ // Risolve il puntamento per l'avvio di una Live. Sempre una risposta utile:
277
+ // i `none` sono modi legittimi di non puntare, e il reason distingue le
278
+ // cause (designazione assente, cella non idonea, fallback su fallimento).
279
+ async function resolveForLive() {
280
+ if (cfg.liveBridgeEnabled !== true) return none('bridge-disabled');
281
+
282
+ let snap;
283
+ try {
284
+ snap = await readDesignation();
285
+ } catch (e) {
286
+ const aborted = e && (e.name === 'AbortError' || /aborted/i.test(String(e.message)));
287
+ return none(aborted ? 'live-host-timeout' : 'live-host-unreachable');
288
+ }
289
+ if (!snap || snap.hostCell == null) return none('no-designation');
290
+ // Tre condizioni diverse, tre nomi: la designazione dichiarata non
291
+ // eleggibile dall'hub, la cella che non esiste piu' nel roster, e la cella
292
+ // che c'e' ma e' spenta. Un nome solo mandava a guardare l'hub anche quando
293
+ // il problema era una sessione chiusa. Rilievo di un audit indipendente.
294
+ if (snap.eligible !== true) return none('host-ineligible');
295
+
296
+ let cell;
297
+ try {
298
+ cell = await rosterCell(snap.hostCell);
299
+ } catch (_) {
300
+ cell = null;
301
+ }
302
+ if (!cell) return none('host-cell-unknown');
303
+ if (cell.active !== true) return none('host-cell-inactive');
304
+ if (typeof cell.cwd !== 'string' || !cell.cwd) return none('cell-cwd-unknown');
305
+
306
+ // JC2: la modalità è una funzione dell'engine, non una scelta. Nativa solo
307
+ // su engine codex-vl (il thread ponte vive nell'app-server del fork); per
308
+ // qualunque altro engine la Live lavora ATTRAVERSO la cella e il ponte non
309
+ // ha nulla da creare qui.
310
+ const engine = String(cell.engine || '');
311
+ const prompt = readCellPrompt(root, snap.hostCell);
312
+
313
+ if (!engine.startsWith('codex-vl')) {
314
+ const out = {
315
+ mode: 'tmux', cell: snap.hostCell, engine: cell.engine || null, cwd: cell.cwd,
316
+ // JC5.5: in modalità tmux le regole le applica la cella; nessuna
317
+ // iniezione da parte del ponte.
318
+ prompt: { applied: false, reason: 'tmux-mode' },
319
+ at: now(),
320
+ };
321
+ log(`[live-bridge] Live su ${snap.hostCell} in modalita' tmux (engine ${engine || 'sconosciuto'})`);
322
+ return out;
323
+ }
324
+
325
+ let started;
326
+ try {
327
+ started = await startThreadOnControlSocket({
328
+ socketPath: cfg.liveBridgeSocketPath,
329
+ cwd: cell.cwd,
330
+ developerInstructions: prompt.applied ? prompt.text : undefined,
331
+ timeoutMs: cfg.liveBridgeTimeoutMs,
332
+ WebSocket,
333
+ log,
334
+ });
335
+ } catch (e) {
336
+ const reason = e && e.code === 'ETIMEOUT' ? 'bridge-timeout' : 'bridge-socket-failed';
337
+ log(`[live-bridge] thread ponte NON creata (${reason}): ${e.message}`);
338
+ return none(reason, { cell: snap.hostCell, detail: String(e.message) });
339
+ }
340
+
341
+ const { text, ...promptEcho } = prompt; // il testo del prompt non viaggia in risposta
342
+ const out = {
343
+ mode: 'native', cell: snap.hostCell, engine,
344
+ threadId: started.threadId, cwd: started.cwd,
345
+ prompt: promptEcho,
346
+ socketPath: cfg.liveBridgeSocketPath,
347
+ at: now(),
348
+ };
349
+ // LC1.4: il puntamento è visibile lato nostro — log con cella, thread e
350
+ // prompt applicato. È il "dirottamento dichiarato" del contratto.
351
+ log(`[live-bridge] Live puntata su ${snap.hostCell}: thread ${started.threadId} (cwd ${started.cwd}, prompt ${prompt.applied ? 'per-cella applicato (sostituisce il globale)' : `non applicato (${promptEcho.reason})`})`);
352
+ return out;
353
+ }
354
+
355
+ return { resolveForLive, readCellPrompt: (cellId) => { const { text, ...rest } = readCellPrompt(root, cellId); return rest; } };
356
+ }
357
+
358
+ let cachedVersion = null;
359
+ function bridgeVersion() {
360
+ if (cachedVersion) return cachedVersion;
361
+ try {
362
+ cachedVersion = require('../../package.json').version || '0';
363
+ } catch (_) {
364
+ cachedVersion = '0';
365
+ }
366
+ return cachedVersion;
367
+ }
368
+
369
+ module.exports = { createLiveBridge, readCellPrompt, startThreadOnControlSocket, CLIENT_NAME };