@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.
- package/CHANGELOG.md +164 -2
- package/README.md +1 -0
- package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
- package/frontend/dist/assets/index-zjL6kZ7J.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/adapters.js +50 -6
- package/lib/cli/commands.js +40 -2
- package/lib/cli/doctor.js +95 -12
- package/lib/cli/init.js +25 -3
- package/lib/cli/path.js +43 -10
- package/lib/cli/pidfile.js +23 -2
- package/lib/config.js +15 -0
- package/lib/fleet/builtin.js +161 -19
- package/lib/fleet/catalogs/opencode-go.json +328 -0
- package/lib/fleet/cell-exec.js +87 -9
- package/lib/fleet/cell-lease-server.js +719 -0
- package/lib/fleet/cell-lease.js +112 -0
- package/lib/fleet/definitions.js +101 -7
- package/lib/fleet/launch-broker.js +115 -3
- package/lib/fleet/lease-client.js +191 -0
- package/lib/fleet/lease-routes.js +92 -0
- package/lib/fleet/lease-verifier.js +230 -0
- package/lib/fleet/managed.js +444 -55
- package/lib/fleet/prompt-delivery.js +50 -2
- package/lib/fleet/provider.js +1 -1
- package/lib/fleet/runtime.js +53 -6
- package/lib/live-host/bridge.js +369 -0
- package/lib/live-host/routes.js +184 -0
- package/lib/live-host/store.js +96 -0
- package/lib/mcp/tools.js +51 -0
- package/lib/nodes/commands.js +9 -2
- package/lib/nodes/store.js +14 -0
- package/lib/nodes/tunnel.js +4 -1
- package/lib/proxy/federation.js +106 -9
- package/lib/proxy/node-proxy.js +33 -0
- package/lib/proxy/panel-auth.js +307 -0
- package/lib/proxy/panel-proxy.js +305 -0
- package/lib/server.js +127 -4
- package/package.json +1 -1
- package/skills/alibaba-token-media/SKILL.md +19 -0
- package/skills/crew/SKILL.md +15 -0
- package/skills/fill-forms/SKILL.md +23 -0
- package/skills/mail-assistant/SKILL.md +15 -0
- package/skills/memory/SKILL.md +15 -0
- package/skills/nexuscrew-agent/SKILL.md +18 -0
- package/skills/vl-msa/SKILL.md +15 -0
- package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
- 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
|
};
|
package/lib/fleet/provider.js
CHANGED
|
@@ -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
|
}
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|