@mmmbuto/nexuscrew 0.9.40 → 0.9.42

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.
@@ -190,8 +190,9 @@ async function deliverBootstrapPrompt(opts = {}) {
190
190
  const readyWaitMs = clampInt(opts.readyWaitMs, 15000, 0, 120000);
191
191
  const pollMs = clampInt(opts.pollMs, 400, 50, 5000);
192
192
  const settleMs = clampInt(opts.settleMs, 150, 0, 5000);
193
- const done = (delivered, state, notReady, attempts) => ({
193
+ const done = (delivered, state, notReady, attempts, mcp) => ({
194
194
  delivered, state, notReady, attempts, reason: state,
195
+ ...(mcp ? { mcp } : {}),
195
196
  });
196
197
  if (!promptCharsOk(prompt)) return done(false, 'prompt-rejected', '', 0);
197
198
 
@@ -217,6 +218,25 @@ async function deliverBootstrapPrompt(opts = {}) {
217
218
  await sleepImpl(pollMs);
218
219
  }
219
220
 
221
+ // (1b) Gate readiness MCP opzionale (solo claude.*): la mcpWait e'
222
+ // fornita dal supervisore, con deadline ASSOLUTA composta sullo stesso
223
+ // orologio del launch (tetto MCP dal budget approvato, mai un budget nuovo a
224
+ // valle). Il composer pronto resta SEMPRE obbligatorio: qui si arriva solo
225
+ // col pane ready. A budget esaurito senza MCP pronti la consegna prosegue
226
+ // come AVVIO DEGRADATO VISIBILE: esito submitted/degraded con l'elenco
227
+ // bounded dei server failed/pending, mai un lancio silenzioso.
228
+ let mcp = null;
229
+ if (typeof opts.mcpWait === 'function') {
230
+ const r = await opts.mcpWait({ isCancelled: cancelled });
231
+ if (cancelled() || !r || r.cancelled) return done(false, 'cancelled', '', 0);
232
+ mcp = {
233
+ state: r.state === 'ready' ? 'ready' : 'degraded',
234
+ ready: Array.isArray(r.ready) ? r.ready : [],
235
+ failed: Array.isArray(r.failed) ? r.failed : [],
236
+ pending: Array.isArray(r.pending) ? r.pending : [],
237
+ };
238
+ }
239
+
220
240
  // (2) Consegna AT-MOST-ONCE: un solo paste per generazione. Il retry copre
221
241
  // SOLO fallimenti certi pre-paste (resolve/load); dopo qualsiasi tentativo
222
242
  // Di paste-buffer non esiste retry automatico.
@@ -239,32 +259,54 @@ async function deliverBootstrapPrompt(opts = {}) {
239
259
  if (paneAgain !== stage.paneId) return done(false, 'delivery-unknown', '', attempts);
240
260
  if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
241
261
  const enter = await exec(tmuxBin, ['send-keys', '-t', stage.paneId, 'Enter'], { env });
242
- if (enter.err) return done(false, 'staged-not-submitted', '', attempts);
262
+ if (enter.err) return done(false, 'staged-not-submitted', '', attempts, mcp);
243
263
  // Cancel con Enter appena partito -> la generazione e' morta con un
244
264
  // submit in volo: esito incerto (residuo PTY possibile), mai 'submitted'.
245
265
  if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
246
- return done(true, 'submitted', '', attempts);
266
+ return done(true, 'submitted', '', attempts, mcp);
247
267
  }
248
268
  if (stage.stage === 'paste' || stage.stage === 'validate') {
249
269
  // Paste tentato (esito incerto) o input rifiutato: zero secondo paste.
250
- return done(false, stage.stage === 'validate' ? 'prompt-rejected' : 'delivery-unknown', '', attempts);
270
+ return done(false, stage.stage === 'validate' ? 'prompt-rejected' : 'delivery-unknown', '', attempts, mcp);
251
271
  }
252
- if (attempts >= 2) return done(false, 'failed-pre-paste', '', attempts);
272
+ if (attempts >= 2) return done(false, 'failed-pre-paste', '', attempts, mcp);
253
273
  // Fallimento certo PRE-paste: un solo retry, readiness riverificata prima.
254
274
  await sleepImpl(pollMs);
255
275
  if (cancelled()) return done(false, 'cancelled', '', attempts);
256
276
  const text = await capture();
257
277
  const again = text === null ? 'unknown' : classifyPane(text, client);
258
- if (again !== 'ready') return done(false, 'failed-pre-paste', '', attempts);
278
+ if (again !== 'ready') return done(false, 'failed-pre-paste', '', attempts, mcp);
259
279
  }
260
280
  }
261
281
 
262
282
  // WaitDeliveryReport(tmuxBin, target, opts) -> delivery-like null.
263
283
  // Il launcher supervisionato (cell-exec) e' l'UNICO owner della consegna per
264
- // TUTTE le generazioni degli engine Kimi e pubblica l'esito bounded sull'
265
- // opzione tmux di pane @nc_delivery ('<state>' o '<state>:<notReady>', solo
266
- // enum chiusi; muore col pane, nessuno state file). up() la legge con attesa
267
- // bounded: niente paste dal runtime, niente doppio writer cross-generation.
284
+ // TUTTE le generazioni degli engine Kimi e claude.* e pubblica l'esito bounded
285
+ // sull'opzione tmux di pane @nc_delivery ('<state>' / '<state>:<notReady>' /
286
+ // '<state>:<notReady>:mcp<state>' dal 0.9.41, solo enum chiusi; muore col
287
+ // pane, nessuno state file). Con degrado MCP l'elenco bounded dei server
288
+ // failed/pending viaggia su @nc_mcp_list ('failed=a,b|pending=c', nomi
289
+ // validati). up() la legge con attesa bounded: niente paste dal runtime,
290
+ // niente doppio writer cross-generation.
291
+ const MCP_REPORT_STATES = Object.freeze(['ready', 'degraded']);
292
+ const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
293
+
294
+ function parseMcpListOption(raw) {
295
+ const out = { failed: [], pending: [] };
296
+ const text = typeof raw === 'string' ? raw.trim() : '';
297
+ if (!text) return out;
298
+ for (const part of text.split('|')) {
299
+ const eq = part.indexOf('=');
300
+ if (eq <= 0) continue;
301
+ const kind = part.slice(0, eq);
302
+ if (kind !== 'failed' && kind !== 'pending') continue;
303
+ for (const nome of part.slice(eq + 1).split(',')) {
304
+ if (nome && MCP_NAME_RE.test(nome) && !out[kind].includes(nome)) out[kind].push(nome);
305
+ }
306
+ }
307
+ return out;
308
+ }
309
+
268
310
  async function waitDeliveryReport(tmuxBin, target, { env, exec, sleepImpl, nowImpl, timeoutMs, pollMs } = {}) {
269
311
  const run = exec || tmuxExec;
270
312
  const sleepFn = sleepImpl || sleep;
@@ -277,13 +319,24 @@ async function waitDeliveryReport(tmuxBin, target, { env, exec, sleepImpl, nowIm
277
319
  { env, timeoutMs: 2000 });
278
320
  const raw = r.err ? '' : r.stdout.trim();
279
321
  if (raw) {
280
- const [state, notReady = ''] = raw.split(':');
322
+ const [state, notReady = '', mcpTag = ''] = raw.split(':');
281
323
  if (!DELIVERY_STATES.includes(state)) return null; // valore non bounded: mai fidarsi
282
324
  const kind = PANE_STATES.includes(notReady) ? notReady : '';
325
+ const mcpState = mcpTag.startsWith('mcp') && MCP_REPORT_STATES.includes(mcpTag.slice(3))
326
+ ? mcpTag.slice(3) : '';
327
+ let mcp = null;
328
+ if (mcpState) {
329
+ const r2 = await run(tmuxBin,
330
+ ['display-message', '-p', '-t', target, '#{@nc_mcp_list}'],
331
+ { env, timeoutMs: 2000 });
332
+ const { failed, pending } = parseMcpListOption(r2.err ? '' : r2.stdout);
333
+ mcp = { state: mcpState, ready: [], failed, pending };
334
+ }
283
335
  return {
284
336
  delivered: state === 'submitted',
285
337
  state, notReady: state === 'skipped-not-ready' ? kind : '',
286
338
  attempts: 0, reason: state,
339
+ ...(mcp ? { mcp } : {}),
287
340
  };
288
341
  }
289
342
  if (now() >= deadline) return null;
@@ -318,6 +371,6 @@ function actionRequiredFor(client, provider, delivery) {
318
371
  }
319
372
 
320
373
  module.exports = {
321
- PANE_STATES, DELIVERY_STATES, ACTION_CODES, RECOVERY_SLUGS,
374
+ PANE_STATES, DELIVERY_STATES, ACTION_CODES, RECOVERY_SLUGS, MCP_REPORT_STATES, parseMcpListOption,
322
375
  classifyPane, deliverBootstrapPrompt, waitDeliveryReport, actionRequiredFor, vlPaneReadiness,
323
376
  };
@@ -17,10 +17,13 @@ const crypto = require('node:crypto');
17
17
  const {
18
18
  loadDefinitions, validateCommandTrust, resolveCwd,
19
19
  } = require('./definitions.js');
20
+ const aiDesktop = require('./ai-desktop.js');
20
21
  const {
21
22
  describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels, extraModelsFrom,
23
+ knownMcpServerNames,
22
24
  } = require('./managed.js');
23
25
  const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
26
+ const { NC_SUPERVISOR_OPT } = require('../tmux/supervisor-pane.js');
24
27
  const {
25
28
  httpError, minimalEnv, tmuxExec,
26
29
  composeClientInvocation, alternateScreenArgs,
@@ -29,6 +32,55 @@ const {
29
32
  } = require('./launch.js');
30
33
  const { waitDeliveryReport, actionRequiredFor, vlPaneReadiness } = require('./prompt-delivery.js');
31
34
 
35
+ // Insieme MCP MATERIALIZZATO della cella — le chiavi del file che
36
+ // writeCellMcpConfig (managed.js) scrive PRIMA dello spawn. Un server non
37
+ // concesso non esiste per il launch, quindi non e' atteso dal gate.
38
+ const MCP_BUDGET_DEFAULT_MS = 20000; // tetto approvato per il gate MCP
39
+
40
+ function cellMcpExpectedServers(home, cellId) {
41
+ const fs = require('node:fs');
42
+ try {
43
+ const p = path.join(home, '.nexuscrew', 'cell-mcp', `${cellId}.json`);
44
+ const st = fs.lstatSync(p);
45
+ if (st.isSymbolicLink() || !st.isFile()) return [];
46
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
47
+ const servers = parsed && typeof parsed === 'object' && parsed.mcpServers
48
+ && typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers)
49
+ ? parsed.mcpServers : null;
50
+ return servers ? Object.keys(servers).sort() : [];
51
+ } catch (_) { return []; }
52
+ }
53
+
54
+ // Celle claude LEGACY (nessun materializzato cell-mcp): l'atteso e' la config
55
+ // nota al client (le tre sorgenti di `knownMcpServerNames`) meno cio' che la
56
+ // cella nega con `cell.mcp`. RESIDUO DICHIARATO: plugin, connettori e
57
+ // configurazione gestita di sistema non sono enumerabili e restano fuori
58
+ // dall'atteso — il gate copre cio' che e' enumerabile, mai promette il totale.
59
+ function legacyMcpExpectedServers(home, cwd, cell) {
60
+ const noti = [...(knownMcpServerNames(home, cwd) || [])];
61
+ if (!noti.length) return [];
62
+ const voluti = cell && Array.isArray(cell.mcp) ? cell.mcp : null;
63
+ if (voluti === null) return [...noti].sort();
64
+ const concessi = new Set(voluti.filter((n) => typeof n === 'string'));
65
+ return [...noti].filter((n) => concessi.has(n)).sort();
66
+ }
67
+
68
+ // Payload readiness per il supervisore: cwd reale della cella + attese + tetto
69
+ // MCP. Priorita' all'insieme MATERIALIZZATO (cell-mcp); per le claude legacy
70
+ // ricade sulla config nota meno i negati. undefined quando la cella non ha
71
+ // server attesi (niente gate: consegna come kimi, che da 0.8.47 non ha gate).
72
+ function buildMcpReadinessPayload(cell, realCwd, home, cfg) {
73
+ const expectedServers = cellMcpExpectedServers(home, cell.id).length
74
+ ? cellMcpExpectedServers(home, cell.id)
75
+ : legacyMcpExpectedServers(home, realCwd, cell);
76
+ if (!expectedServers.length) return undefined;
77
+ return {
78
+ cwd: realCwd,
79
+ expectedServers,
80
+ budgetMs: Math.max(0, Math.min(120000, Number(cfg && cfg.mcpReadyBudgetMs) || MCP_BUDGET_DEFAULT_MS)),
81
+ };
82
+ }
83
+
32
84
  // TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
33
85
  const STATUS_TTL_MS = 2000;
34
86
 
@@ -127,7 +179,12 @@ function createBuiltinRuntime(ctx) {
127
179
  // engineDef gia' usata sopra per la permission policy: un valore
128
180
  // presente qui e' gia' passato da validPanelUrl a monte (parseEngine/
129
181
  // parseCell), quindi si copia, non si ri-valida.
130
- const panelUrl = c.panelUrl || engineDef?.panelUrl || '';
182
+ // La spunta del desktop grafico taglia qui il pannello del container
183
+ // quando è esplicitamente OFF: il campo non esce dallo stato, quindi il
184
+ // tasto e la sorgente Pannello spariscono da OGNI superficie senza un
185
+ // if nel frontend. Solo la chiave esplicita comanda: qui non si
186
+ // interroga mai docker (percorso caldo a polling).
187
+ const panelUrl = aiDesktop.pubblicaPanelUrlCaldo(c.panelUrl || engineDef?.panelUrl || '');
131
188
  return {
132
189
  // `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
133
190
  // leggibile e viaggia accanto, senza mai sostituirlo.
@@ -239,9 +296,48 @@ function createBuiltinRuntime(ctx) {
239
296
  if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
240
297
  const engine = findEngine(defs, cell.engine);
241
298
  if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
299
+ // (0) Una cella GIA' VIVA non si rilancia — e soprattutto non si risolve.
300
+ // Senza questo controllo l'ordine era: si risolve lo spec (che PUBBLICA la
301
+ // generazione di attivita', managed.js:1696), si chiede il ticket, e solo
302
+ // alla new-session si scopre che la sessione esiste gia' -> 409. Il file
303
+ // `activity.gen` restava pero' riscritto con una generazione nuova, mentre
304
+ // gli hook del lancio vero portano quella vecchia: lo stato di quella cella
305
+ // veniva scartato (files/activity.js:336-338) e la UI diceva «non
306
+ // verificata» su una cella che stava lavorando. Lo pagava qualunque `up` su
307
+ // una cella viva: l'operatore dalla UI, e `fleet-boot` all'avvio del
308
+ // servizio, che cicla TUTTE le celle boot:true e su quelle gia' in
309
+ // esecuzione si limitava a saltare (boot.js:49) — dopo aver riscritto il
310
+ // file.
311
+ // Il 409 in fondo (new-session) resta come rete per la corsa fra questo
312
+ // controllo e la creazione della sessione.
313
+ const vive = await refreshSessions();
314
+ if (vive.has(String(cell.tmuxSession))) {
315
+ throw httpError(409, 'sessione già in esecuzione', null,
316
+ { phase: 'preflight', code: 'SESSION_DUPLICATE' });
317
+ }
242
318
  let launchEngine = engine;
319
+ // Canale di attivita' della cella: `resolved` vive solo dentro il ramo
320
+ // managed, e il payload del broker si costruisce FUORI. Portarlo fuori con
321
+ // una variabile dichiarata qui evita di usare, in un ramo, un nome
322
+ // dichiarato in un altro: un errore che in produzione fa fallire il lancio
323
+ // di ogni cella, non una sola asserzione.
324
+ let activityCanale = null;
243
325
  if (engine.managed) {
244
- const resolved = resolveManagedEngine(engine, cell, { ...cfg, home, extraModels: extraModelsFrom(defs), capabilityProfiles: defs.capabilityProfiles || null });
326
+ // Generazione del lancio: identifica QUESTA partenza della cella. Uno
327
+ // stato di attivita' che ne porta un'altra appartiene a un lancio
328
+ // precedente e viene scartato — una cella riavviata non eredita il
329
+ // «lavora» di prima.
330
+ const generazione = crypto.randomBytes(8).toString('hex');
331
+ const resolved = resolveManagedEngine(engine, cell, {
332
+ ...cfg, home, extraModels: extraModelsFrom(defs),
333
+ capabilityProfiles: defs.capabilityProfiles || null,
334
+ activityGeneration: generazione,
335
+ // La generazione NON si scrive qui: viaggia negli argv degli hook e nel
336
+ // payload del broker, e a scriverla su disco e' `cell-exec`, dentro la
337
+ // sessione che tmux ha appena creato — quindi solo per chi vince la
338
+ // new-session. Chi perde la corsa scriverebbe una generazione che non e'
339
+ // quella di nessun lancio vivo.
340
+ });
245
341
  // Il materializzatore MCP per cella e' FAIL-CLOSED: se il file per cella
246
342
  // non si scrive, la cella non parte — mai con la superficie intera dove
247
343
  // l'operatore l'ha ristretta.
@@ -253,6 +349,10 @@ function createBuiltinRuntime(ctx) {
253
349
  throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
254
350
  }
255
351
  launchEngine = resolved.engine;
352
+ // Qui si porta FUORI dal ramo il solo dato che serve al payload: dove e
353
+ // con quale generazione il supervisore dovra' PUBBLICARLA su disco e
354
+ // dichiarare l'uscita del client.
355
+ if (resolved.activityDir) activityCanale = { dir: resolved.activityDir, generation: generazione };
256
356
  // A vl cell whose runtime cannot hold the per-cell prompt
257
357
  // (VL_SYSTEM_APPEND_FILE, 0.3.1+) DEGRADES by declaring it — same
258
358
  // shape as readinessDegraded: the cell starts without its own identity
@@ -327,6 +427,13 @@ function createBuiltinRuntime(ctx) {
327
427
  ...(typeof launchEngine.identityChannel === 'boolean'
328
428
  ? { identityChannel: launchEngine.identityChannel } : {}),
329
429
  ...(leaseInfo ? { lease: { cellId: cell.id, launchEpoch: leaseInfo.launchEpoch, stablePath: leaseInfo.stablePath } } : {}),
430
+ // Canale di attivita' verso il supervisore: il riavvio del client
431
+ // avviene DENTRO lo stesso lancio, quindi la generazione non cambia e
432
+ // l'unico modo di invalidare lo stato del client morto e' che sia
433
+ // cell-exec a dichiararlo. Presente solo dove gli hook sono davvero
434
+ // iniettati (`activityDir` lo dice): senza canale non c'e' niente da
435
+ // invalidare.
436
+ ...(activityCanale ? { activity: activityCanale } : {}),
330
437
  supervise: {
331
438
  enabled: !launchEngine.shellOneShot,
332
439
  initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
@@ -342,12 +449,17 @@ function createBuiltinRuntime(ctx) {
342
449
  tmuxSession: cell.tmuxSession,
343
450
  prompt: cell.prompt,
344
451
  readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
345
- // Solo gli engine managed Kimi (kimi.native / claude.kimi-code) hanno
346
- // la delivery classificata at-most-once ai restart; gli engine custom
347
- // Send-keys conservano il reinject legacy senza Enter (0.8.47).
348
- client: engine.managed && engine.managed.client === 'kimi' ? 'kimi'
349
- : (engine.managed && engine.managed.provider === 'kimi-code' ? 'claude' : ''),
452
+ // kimi.* e TUTTE le claude.* hanno la delivery classificata
453
+ // at-most-once ai restart; gli engine custom send-keys conservano
454
+ // il reinject legacy senza Enter (0.8.47).
455
+ client: engine.managed && (engine.managed.client === 'kimi' || engine.managed.client === 'claude')
456
+ ? engine.managed.client : '',
350
457
  readyWaitMs: Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000)),
458
+ // Gate readiness MCP solo per le claude.* (budget composto col
459
+ // readyWaitMs: tetto MCP dal launch, avvio degradato visibile).
460
+ ...(engine.managed && engine.managed.client === 'claude' ? {
461
+ mcpReadiness: buildMcpReadinessPayload(cell, realCwd, home, cfg),
462
+ } : {}),
351
463
  },
352
464
  } : {}),
353
465
  });
@@ -398,6 +510,26 @@ function createBuiltinRuntime(ctx) {
398
510
  throw httpError(dup ? 409 : 500, why, null,
399
511
  { phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
400
512
  }
513
+ // Il pane del SUPERVISORE si marca ORA, sul pane che esegue cell-exec: e'
514
+ // l'identita' stabile della cella viva. Il server cerca questo marcatore
515
+ // (`list-panes`) perche' `#{pane_dead}` di list-sessions riguarda il pane
516
+ // ATTIVO — con una seconda finestra viva selezionata direbbe «vivo» anche
517
+ // con il supervisore morto. L'id ce l'abbiamo gia' da `-P -F` (sotto), non
518
+ // serve una ricerca. Best effort come le opzioni che seguono: un tmux che
519
+ // non accetta l'opzione non deve impedire l'avvio — la cella resta senza
520
+ // marcatore, e per lei il server dira' «non verificato» invece di leggere
521
+ // uno stato di cui non sa identificare il supervisore.
522
+ const idPaneLancio = /^%[0-9]+$/.test((create.stdout.trim().split('\n')[0] || '').split('\t')[2] || '')
523
+ ? (create.stdout.trim().split('\n')[0] || '').split('\t')[2] : '';
524
+ if (idPaneLancio) {
525
+ const marcato = await tmuxExec(tmuxBin,
526
+ ['set-option', '-p', '-t', idPaneLancio, NC_SUPERVISOR_OPT, '1'],
527
+ { env: minimalEnv(), timeoutMs: 2000 });
528
+ if (marcato.err) {
529
+ const log = typeof cfg.log === 'function' ? cfg.log : console.warn;
530
+ log(`fleet marcatore del pane supervisore non impostato per ${cell.id}; la cella resta non identificabile`);
531
+ }
532
+ }
401
533
  // Best effort per la sola sessione appena creata: non tocchiamo mai
402
534
  // ~/.tmux.conf, opzioni globali o sessioni preesistenti. Un tmux che non
403
535
  // accetta una delle opzioni non deve impedire l'avvio della cella.
@@ -557,14 +689,20 @@ function createBuiltinRuntime(ctx) {
557
689
  // Paste SENZA Enter, contratto invariato —), target %N esatto.
558
690
  let prompt = null;
559
691
  let actionRequired = null;
692
+ let mcpDegraded = null;
560
693
  if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
561
694
  const managedClient = engine.managed && typeof engine.managed.client === 'string' ? engine.managed.client : '';
562
695
  const managedProvider = engine.managed && typeof engine.managed.provider === 'string' ? engine.managed.provider : '';
563
- const classified = managedClient === 'kimi' || managedProvider === 'kimi-code';
696
+ const classified = managedClient === 'kimi' || managedClient === 'claude';
564
697
  if (classified) {
565
698
  const readyWaitMs = Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000));
699
+ // Budget unico composto — il report copre anche il tetto MCP del
700
+ // gate (l'attesa MCP consuma lo stesso orologio del launch).
701
+ const mcpBudgetMs = managedClient === 'claude'
702
+ ? Math.max(0, Math.min(120000, Number(cfg.mcpReadyBudgetMs) || MCP_BUDGET_DEFAULT_MS))
703
+ : 0;
566
704
  const reportWaitMs = Math.max(100, Math.min(150000,
567
- (Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + 2000));
705
+ (Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + mcpBudgetMs + 2000));
568
706
  const report = await waitDeliveryReport(tmuxBin,
569
707
  paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`,
570
708
  { env: minimalEnv(), timeoutMs: reportWaitMs });
@@ -576,8 +714,17 @@ function createBuiltinRuntime(ctx) {
576
714
  delivered: delivery.delivered,
577
715
  state: delivery.state,
578
716
  reason: delivery.reason,
717
+ ...(delivery.mcp ? { mcp: delivery.mcp } : {}),
579
718
  };
580
719
  actionRequired = actionRequiredFor(managedClient, managedProvider, delivery);
720
+ // Avvio degradato visibile: l'elenco bounded dei server MCP non pronti
721
+ // sale alla UI con il flag esistente readinessDegraded.
722
+ if (delivery.mcp && delivery.mcp.state === 'degraded') {
723
+ mcpDegraded = {
724
+ failed: Array.isArray(delivery.mcp.failed) ? delivery.mcp.failed : [],
725
+ pending: Array.isArray(delivery.mcp.pending) ? delivery.mcp.pending : [],
726
+ };
727
+ }
581
728
  } else {
582
729
  const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
583
730
  prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
@@ -593,6 +740,7 @@ function createBuiltinRuntime(ctx) {
593
740
  ok: true, cell: cellId, session: cell.tmuxSession, prompt,
594
741
  ...(actionRequired ? { actionRequired } : {}),
595
742
  ...(readinessDegraded ? { readinessDegraded: true } : {}),
743
+ ...(mcpDegraded ? { readinessDegraded: true, mcpDegraded } : {}),
596
744
  // Per-cell prompt not delivered to a vl cell (old runtime or
597
745
  // unwritable file). Strict boolean like readinessDegraded: the
598
746
  // payload may come from a federated node and the text is local i18n.
@@ -630,4 +778,4 @@ function createBuiltinRuntime(ctx) {
630
778
  };
631
779
  }
632
780
 
633
- module.exports = { createBuiltinRuntime, findCell, findEngine, identityLaunchRefusal, STATUS_TTL_MS };
781
+ module.exports = { createBuiltinRuntime, findCell, findEngine, identityLaunchRefusal, STATUS_TTL_MS, buildMcpReadinessPayload, cellMcpExpectedServers };
@@ -14,23 +14,66 @@
14
14
  // refused locally until verification resolves the attempt.
15
15
 
16
16
  const crypto = require('node:crypto');
17
+ const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
17
18
  const IDLE_MS = 15000;
18
19
 
19
- function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {} } = {}) {
20
+ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot } = {}) {
20
21
  // (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
21
22
  const attempts = new Map();
22
23
 
23
24
  function requestId() { return crypto.randomUUID(); }
24
25
 
25
- function resolveOwner(ownerId) {
26
+ // Risoluzione del produttore: peer OUTBOUND (canale proprio, come sempre)
27
+ // oppure peer INBOUND condiviso con consenso eventi+risposta e slot reverse
28
+ // attivo. Per gli inbound la porta dello slot DEVE essere verificata con la
29
+ // stessa sonda dei terminali (secret del peer + instanceId atteso): nessun
30
+ // fallback sulla localPort senza proof.
31
+ function candidateOwner(ownerId) {
26
32
  const store = loadStore();
27
33
  if (!store) return null;
28
- return (store.nodes || []).find((n) => n && n.direction === 'outbound'
29
- && n.nodeId === ownerId && n.token && n.localPort) || null;
34
+ const nodes = store.nodes || [];
35
+ const out = nodes.find((n) => n && n.direction === 'outbound'
36
+ && n.nodeId === ownerId && n.token && n.localPort);
37
+ if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound' };
38
+ const inbound = nodes.find((n) => n && n.direction === 'inbound'
39
+ && n.shared === true && n.nodeId === ownerId
40
+ && (n.token || n.acceptToken)
41
+ && n.askReplyAccess === true && n.eventsAccess === true
42
+ && n.cellVisibility !== 'none');
43
+ if (!inbound) return null;
44
+ const forwardToken = inbound.token || inbound.acceptToken;
45
+ const pool = inbound.reversePool;
46
+ const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
47
+ if (!slot || !Number.isInteger(slot.port)) {
48
+ return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified' };
49
+ }
50
+ return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound' };
51
+ }
52
+
53
+ function ownerUrl(port, path) {
54
+ return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
30
55
  }
31
56
 
32
- function ownerUrl(peer, path) {
33
- return `http://127.0.0.1:${peer.localPort}/federation/route/_/${path.replace(/^\//, '')}`;
57
+ // Preflight del canale reverse per un peer inbound: rifiuto NETTO se lo slot
58
+ // non e' verificato (non e' un esito incerto: il forward non e' mai partito).
59
+ async function verifyInboundChannel(candidate) {
60
+ const { node, forwardPort } = candidate;
61
+ const pool = node.reversePool;
62
+ const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
63
+ const proof = await probeReverseSlotImpl({
64
+ port: forwardPort, secret: node.token,
65
+ expected: {
66
+ remotePort: forwardPort,
67
+ generation: pool && Number.isInteger(pool.activeGeneration) ? pool.activeGeneration : undefined,
68
+ instanceId: node.nodeId,
69
+ },
70
+ });
71
+ if (proof && proof.owned === true) return { ok: true };
72
+ return {
73
+ ok: false, code: 404,
74
+ error: 'canale reverse non verificato: risposta non inoltrata',
75
+ reason: 'reverse-slot-unverified',
76
+ };
34
77
  }
35
78
 
36
79
  function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
@@ -47,16 +90,35 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
47
90
  return statusFor(ownerId, askId).some((a) => a.state === 'uncertain');
48
91
  }
49
92
 
93
+ // Il blocco precedente rifiuta il nuovo requestId PRIMA di creare la sua
94
+ // ricevuta: la sola verifica possibile e' sull'ID originale ancora 'sent'
95
+ // (se esiste), altrimenti resta solo la riconciliazione con l'owner.
96
+ function originalSentRequestId(ownerId, askId, excludeRid) {
97
+ const sent = statusFor(ownerId, askId)
98
+ .filter((a) => (a.state === 'sent' || a.state === 'uncertain') && a.requestId !== excludeRid);
99
+ return sent.length ? sent[sent.length - 1].requestId : null;
100
+ }
101
+
50
102
  async function relayAnswer({ ownerId, askId, text, rid: providedRid } = {}) {
51
103
  if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
52
104
  return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
53
105
  }
54
- const peer = resolveOwner(ownerId);
55
- if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
106
+ const candidate = candidateOwner(ownerId);
107
+ if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
108
+ if (candidate.kind === 'inbound-unverified') {
109
+ return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
110
+ }
111
+ if (candidate.kind === 'inbound') {
112
+ const gate = await verifyInboundChannel(candidate);
113
+ if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
114
+ }
115
+ const peer = candidate.node;
56
116
  // Local reconciliation FIRST: an uncertain attempt on this ask must be
57
117
  // verified before any new paste leaves this node.
58
118
  if (isUncertain(ownerId, askId)) {
59
- return { ok: false, code: 409, error: 'esito incerto: verifica lo stato prima di rispondere', reason: 'uncertain', uncertain: true };
119
+ // L'ID originale e' quello verificabile: il nuovo invio non e' mai partito.
120
+ return { ok: false, code: 409, error: 'esito incerto: verifica lo stato prima di rispondere', reason: 'uncertain', uncertain: true,
121
+ originalRequestId: originalSentRequestId(ownerId, askId) };
60
122
  }
61
123
  const rid = providedRid || requestId();
62
124
  const key = attemptKey(ownerId, askId, rid);
@@ -65,10 +127,10 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
65
127
  const ctrl = new AbortController();
66
128
  const timer = setTimeout(() => ctrl.abort(), IDLE_MS);
67
129
  try {
68
- const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}/answer`), {
130
+ const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/answer`), {
69
131
  method: 'POST',
70
132
  headers: {
71
- authorization: `Bearer ${peer.token}`,
133
+ authorization: `Bearer ${candidate.forwardToken}`,
72
134
  'content-type': 'application/json',
73
135
  'x-nexuscrew-visited': store.nodeId,
74
136
  },
@@ -86,6 +148,13 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
86
148
  // could have pasted (timeout-ish) parks as uncertain, never retried.
87
149
  if (r.status === 409) {
88
150
  if (entry) entry.state = body.reason === 'delivery-unknown-block' ? 'uncertain' : 'failed';
151
+ if (body.reason === 'delivery-unknown-block') {
152
+ // Stesso contratto del ramo 502: la UI riceve 200+uncertain e non
153
+ // ripete mai il paste. La verifica possibile e' sull'ID originale.
154
+ return { ok: false, code: 200, uncertain: true, reason: body.reason,
155
+ originalRequestId: originalSentRequestId(ownerId, askId, rid), requestId: rid,
156
+ error: 'esito incerto: bloccato da un invio precedente' };
157
+ }
89
158
  return { ok: false, code: 409, error: body.error || 'conflict', reason: body.reason };
90
159
  }
91
160
  if (r.status === 502) {
@@ -108,13 +177,21 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
108
177
 
109
178
  // Remote dismiss: same resolution rules as the answer (DELETE on the owner).
110
179
  async function relayDismiss({ ownerId, askId } = {}) {
111
- const peer = resolveOwner(ownerId);
112
- if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati' };
180
+ const candidate = candidateOwner(ownerId);
181
+ if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
182
+ if (candidate.kind === 'inbound-unverified') {
183
+ return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
184
+ }
185
+ if (candidate.kind === 'inbound') {
186
+ const gate = await verifyInboundChannel(candidate);
187
+ if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
188
+ }
189
+ const peer = candidate.node;
113
190
  const store = loadStore();
114
191
  try {
115
- const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}`), {
192
+ const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}`), {
116
193
  method: 'DELETE',
117
- headers: { authorization: `Bearer ${peer.token}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
194
+ headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
118
195
  });
119
196
  const body = await r.json().catch(() => ({}));
120
197
  if (!r.ok) return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}` };
@@ -123,12 +200,20 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
123
200
  }
124
201
 
125
202
  async function verifyStatus({ ownerId, askId, requestId: rid }) {
126
- const peer = resolveOwner(ownerId);
127
- if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati' };
203
+ const candidate = candidateOwner(ownerId);
204
+ if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
205
+ if (candidate.kind === 'inbound-unverified') {
206
+ return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
207
+ }
208
+ if (candidate.kind === 'inbound') {
209
+ const gate = await verifyInboundChannel(candidate);
210
+ if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
211
+ }
212
+ const peer = candidate.node;
128
213
  const store = loadStore();
129
214
  try {
130
- const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}/requests/${rid}`), {
131
- headers: { authorization: `Bearer ${peer.token}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
215
+ const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/requests/${rid}`), {
216
+ headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
132
217
  });
133
218
  if (!r.ok) return { ok: false, code: r.status };
134
219
  const body = await r.json();
@@ -219,6 +219,20 @@ function respond(res, code, body) {
219
219
  res.end(payload);
220
220
  }
221
221
 
222
+ // Il frame NON deve finire carico-bianco su un pannello morto: il 502 qui è
223
+ // UNA PAGINA (l'iframe spara `load` anche sulle risposte d'errore), quindi la
224
+ // pagina dice sé stessa a chi la monta — un postMessage con la causa. Il
225
+ // contenuto è fisso e NON sensibile (un tipo, nessun dato), quindi il
226
+ // targetOrigin è '*': può nascere un padre di origine diversa (il frame viaggia
227
+ // sulla porta pannello separata proprio per isolare le origini). La fiducia la
228
+ // mette chi RICEVE: source del frame attivo + origine del frame + tipo atteso.
229
+ function respondPaginaNonRaggiungibile(res) {
230
+ res.writeHead(502, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
231
+ res.end('<!doctype html><html><body><p>pannello non raggiungibile</p><script>('
232
+ + 'function(){try{window.parent.postMessage({type:"nc-panel-unreachable"},"*");}catch(_){}}'
233
+ + ')()</script></body></html>');
234
+ }
235
+
222
236
  function createPanelProxy({ resolveCellPanel, log = () => {}, requestImpl }) {
223
237
  return async function panelProxy(req, res) {
224
238
  const parsed = splitPanelPath(req.url);
@@ -248,13 +262,13 @@ function createPanelProxy({ resolveCellPanel, log = () => {}, requestImpl }) {
248
262
  });
249
263
  } catch (_) {
250
264
  log({ event: 'panel-proxy', outcome: 'error', reason: 'request-failed', cell: parsed.cellId });
251
- if (!res.headersSent) respond(res, 502, { error: 'pannello non raggiungibile' });
265
+ if (!res.headersSent) respondPaginaNonRaggiungibile(res);
252
266
  return;
253
267
  }
254
268
  upstream.setTimeout(PANEL_TIMEOUT_MS, () => upstream.destroy(new Error('panel timeout')));
255
269
  upstream.on('error', () => {
256
270
  log({ event: 'panel-proxy', outcome: 'error', reason: 'upstream-error', cell: parsed.cellId });
257
- if (!res.headersSent) respond(res, 502, { error: 'pannello non raggiungibile' });
271
+ if (!res.headersSent) respondPaginaNonRaggiungibile(res);
258
272
  else res.destroy();
259
273
  });
260
274
  req.on('aborted', () => upstream.destroy());