@mmmbuto/nexuscrew 0.9.40 → 0.9.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-BJBurY16.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-Csz8VaFz.css">
14
+ <script type="module" crossorigin src="/assets/index-DGa2FEN2.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-Ca1Rl40n.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.40"}
1
+ {"version":"0.9.41"}
@@ -0,0 +1,294 @@
1
+ 'use strict';
2
+ // Attivita' per-cella: lo stato del turno pubblicato dagli HOOK di Claude Code.
3
+ //
4
+ // Perche' un file proprio e non telemetry.json: la statusline e gli hook sono
5
+ // due scrittori indipendenti. Un read-modify-write non coordinato perde gli
6
+ // aggiornamenti dell'altro, e un timestamp unico farebbe sembrare fresco uno
7
+ // stato attività vecchio quando arrivano nuove percentuali. Due file, due
8
+ // contratti, un solo scrittore ciascuno.
9
+ //
10
+ // Il file NON contiene il payload dell'hook: quello porta il testo del prompt e
11
+ // i percorsi della sessione, che allo stato non servono. Solo l'evento, il
12
+ // timestamp, l'identita' della sessione e la generazione.
13
+ //
14
+ // Quattro regole, le stesse di telemetry.js ma con esito diverso:
15
+ // 1. TIMESTAMP OBBLIGATORIO e guardato nei DUE versi — oltre la finestra il
16
+ // dato e' morto, e un ts nel futuro non deve restare fresco per sempre.
17
+ // 2. SCADUTO NON E' IDLE: oltre la finestra si torna `null`, che la UI mostra
18
+ // come «non verificato». Mai affermare «ferma» su un dato che non
19
+ // verifica nulla: e' l'unico modo di coprire l'interruzione con Ctrl-C,
20
+ // che (misurato su Claude Code 2.1.280) NON emette alcun evento.
21
+ // 3. GENERAZIONE: lo stato di un lancio precedente della stessa cella non
22
+ // vale per quello corrente. La generazione corrente la scrive il launcher
23
+ // (activity.gen); un file che ne porta un'altra viene scartato.
24
+ // 4. LETTURA TOLLERANTE: file assente, illeggibile o JSON rotto -> null, mai
25
+ // un'eccezione: la lista delle sessioni non deve fallire per questo.
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const crypto = require('node:crypto');
30
+
31
+ const NOME_FILE = 'activity.json';
32
+ const NOME_GENERAZIONE = 'activity.gen';
33
+
34
+ // Un turno che usa tool rinnova la freschezza a ogni PreToolUse/PostToolUse,
35
+ // quindi la finestra copre l'attesa fra un tool e il successivo. Un turno lungo
36
+ // SENZA tool puo' invece superarla: in quel caso lo stato diventa «non
37
+ // verificato», che e' l'esito voluto — meglio un'incertezza dichiarata di un
38
+ // «al lavoro» affermato senza prova.
39
+ const MASSIMA_ETA_MS = 5 * 60 * 1000;
40
+ const FUTURO_TOLLERATO_MS = 2 * 60 * 1000;
41
+
42
+ const LAVORA = 'lavora';
43
+ const FERMA = 'ferma';
44
+ const ATTESA = 'attesa';
45
+
46
+ // La matrice evento -> stato e' quella MISURATA nel Gate A su Claude Code
47
+ // 2.1.280, non quella attesa sulla carta. In particolare:
48
+ // - `SubagentStop` NON chiude il turno: il principale sta ancora lavorando,
49
+ // quindi rinnova la freschezza restando su «lavora». Usarlo come «ferma»
50
+ // sarebbe un falso negativo (il figlio finisce, il padre no).
51
+ // - `SessionEnd` non afferma «ferma»: quella sessione non esiste piu', e
52
+ // quello che resta da dire e' che il dato non e' piu' verificabile.
53
+ // - `Notification` vale solo se il tipo e' la richiesta di permesso: gli altri
54
+ // tipi non sono stati misurati e non autorizzano nessuna transizione.
55
+ const STATO_PER_EVENTO = Object.freeze({
56
+ SessionStart: FERMA,
57
+ UserPromptSubmit: LAVORA,
58
+ PreToolUse: LAVORA,
59
+ PostToolUse: LAVORA,
60
+ SubagentStop: LAVORA,
61
+ Stop: FERMA,
62
+ PermissionRequest: ATTESA,
63
+ });
64
+
65
+ const TIPO_PERMESSO = 'permission_prompt';
66
+
67
+ /**
68
+ * Stato dichiarato da un evento, oppure null se l'evento non autorizza nessuna
69
+ * transizione (o non e' un evento noto). Non lancia mai.
70
+ */
71
+ function statoDaEvento(evento, tipoNotifica) {
72
+ if (typeof evento !== 'string' || !evento) return null;
73
+ if (evento === 'Notification') {
74
+ return tipoNotifica === TIPO_PERMESSO ? ATTESA : null;
75
+ }
76
+ if (evento === 'SessionEnd') return null;
77
+ return Object.prototype.hasOwnProperty.call(STATO_PER_EVENTO, evento)
78
+ ? STATO_PER_EVENTO[evento]
79
+ : null;
80
+ }
81
+
82
+ /**
83
+ * Se questo evento debba essere SCRITTO sul file, oppure no.
84
+ *
85
+ * Non e' la stessa domanda di `statoDaEvento`. Due casi la separano:
86
+ * - `SessionEnd` non dichiara uno stato, ma DEVE essere scritto: e' cosi' che
87
+ * si invalida il dato di una sessione che non esiste piu' (il lettore,
88
+ * trovandolo, torna null). Non scriverlo lascerebbe in giro lo stato
89
+ * precedente — «lavora» o «ferma» — per tutta la finestra.
90
+ * - una `Notification` di tipo diverso da `permission_prompt` non deve
91
+ * TOCCARE il file: se scrivesse, sovrascriverebbe uno stato fresco e
92
+ * legittimo («al lavoro») con un evento che il lettore non sa mappare, e la
93
+ * cella diventerebbe «non verificata» mentre sta lavorando. Il caso e'
94
+ * COSTRUITO, non misurato: di `notification_type` e' stato osservato un solo
95
+ * valore (`permission_prompt`), gli altri non sono enumerati. La prudenza
96
+ * sta proprio qui — non si mappa cio' che non si e' misurato, e non si
97
+ * scrive un evento che poi il lettore scarterebbe.
98
+ *
99
+ * Un evento sconosciuto non scrive: non si tocca cio' che non si sa leggere.
100
+ */
101
+ function daRegistrare(evento, tipoNotifica) {
102
+ if (typeof evento !== 'string' || !evento) return false;
103
+ if (evento === 'SessionEnd') return true;
104
+ if (evento === 'Notification') return tipoNotifica === TIPO_PERMESSO;
105
+ return Object.prototype.hasOwnProperty.call(STATO_PER_EVENTO, evento);
106
+ }
107
+
108
+ function leggiGenerazioneCorrente(dirSessione) {
109
+ try {
110
+ const raw = fs.readFileSync(path.join(dirSessione, NOME_GENERAZIONE), 'utf8').trim();
111
+ return raw || null;
112
+ } catch (_) { return null; }
113
+ }
114
+
115
+ /**
116
+ * Scrive la generazione corrente della cella. Chiamata dal launcher al momento
117
+ * del lancio: e' l'unico scrittore di questo file.
118
+ */
119
+ function scriviGenerazione(dirSessione, generazione) {
120
+ if (typeof generazione !== 'string' || !generazione) return false;
121
+ const destinazione = path.join(dirSessione, NOME_GENERAZIONE);
122
+ let tmp = null;
123
+ try {
124
+ fs.mkdirSync(dirSessione, { recursive: true });
125
+ // Stessa ragione di `scriviStato`: un nome di temporaneo condiviso perde la
126
+ // scrittura di chi arriva secondo. Qui il rischio e' piu' basso (il
127
+ // launcher e' l'unico scrittore), ma il difetto sarebbe identico.
128
+ tmp = tmpUnivoco(dirSessione, NOME_GENERAZIONE);
129
+ fs.writeFileSync(tmp, generazione, { encoding: 'utf8', mode: 0o600 });
130
+ fs.renameSync(tmp, destinazione);
131
+ return true;
132
+ } catch (_) {
133
+ if (tmp) { try { fs.unlinkSync(tmp); } catch (_) {} }
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Nome di un temporaneo ESCLUSIVO di questo processo.
140
+ *
141
+ * Non basta `file.tmp`: gli hook sono processi distinti che possono scrivere
142
+ * nello stesso istante, e con un nome condiviso si sovrascrivono il temporaneo
143
+ * a vicenda — il secondo `rename` trova il file gia' consumato e fallisce con
144
+ * ENOENT, e quella scrittura e' persa. Uno `Stop` perso lascia la cella su
145
+ * «lavora» fino alla scadenza: e' il sintomo che questo modulo esiste per
146
+ * togliere. Stessa forma gia' usata per il profilo MCP di cella.
147
+ */
148
+ function tmpUnivoco(dirSessione, nome) {
149
+ return path.join(dirSessione, `${nome}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
150
+ }
151
+
152
+ // Sezione critica per cella, con un lock di file.
153
+ //
154
+ // Perche' non basta il ri-controllo prima del `rename`: due scrittori possono
155
+ // leggere lo stesso ts vecchio, scrivere i propri temporanei e pubblicare
156
+ // entrambi — l'ultimo `rename` vince, e puo' essere il piu' VECCHIO. Misurato:
157
+ // con ts ravvicinati l'evento piu' recente perdeva il confronto nel 12-20% dei
158
+ // giri. Il confronto e la pubblicazione devono stare nella stessa sezione
159
+ // critica, o l'ordinamento non e' garantito.
160
+ //
161
+ // Il lock ha un guasto proprio — un processo ucciso dentro la sezione critica
162
+ // lo lascia orfano — e per questo NON puo' bloccare nulla per sempre:
163
+ // - un lock piu' vecchio di un secondo si considera orfano e viene scavalcato
164
+ // (la sezione critica sono due syscall: un lock di un secondo non e' lavoro
165
+ // in corso, e' un morto);
166
+ // - se entro il budget non lo si ottiene, si scrive COMUNQUE. Nel caso
167
+ // peggiore si torna al comportamento senza lock, che perde l'ordine in una
168
+ // corsa stretta ma non perde mai una scrittura: marcire in silenzio sarebbe
169
+ // peggio, perche' la cella resterebbe «al lavoro» per sempre.
170
+ const BUDGET_LOCK_MS = 2000;
171
+ const ETA_ORFANO_MS = 1000;
172
+
173
+ function conLock(dirSessione, fn) {
174
+ const lock = path.join(dirSessione, `${NOME_FILE}.lock`);
175
+ const scadenza = Date.now() + BUDGET_LOCK_MS;
176
+ for (;;) {
177
+ try {
178
+ const fd = fs.openSync(lock, 'wx', 0o600);
179
+ fs.closeSync(fd);
180
+ try { return fn(); } finally { try { fs.unlinkSync(lock); } catch (_) {} }
181
+ } catch (e) {
182
+ if (e.code !== 'EEXIST') return fn(); // lock non praticabile: si scrive lo stesso
183
+ try {
184
+ if (Date.now() - fs.statSync(lock).mtimeMs > ETA_ORFANO_MS) { fs.unlinkSync(lock); continue; }
185
+ } catch (_) { continue; } // sparito mentre lo si guardava
186
+ if (Date.now() > scadenza) return fn();
187
+ const fine = Date.now() + 2; while (Date.now() < fine); // attesa breve
188
+ }
189
+ }
190
+ }
191
+
192
+ // Il ts gia' pubblicato, o null se non c'e' / non e' leggibile.
193
+ function tsPubblicato(dirSessione) {
194
+ try {
195
+ const dato = JSON.parse(fs.readFileSync(path.join(dirSessione, NOME_FILE), 'utf8'));
196
+ const ts = Number(dato && dato.ts);
197
+ return Number.isFinite(ts) ? ts : null;
198
+ } catch (_) { return null; }
199
+ }
200
+
201
+ /**
202
+ * Scrittura atomica dello stato: temporaneo ESCLUSIVO + rename nella stessa
203
+ * directory. Piu' hook possono scattare a raffica (PreToolUse/PostToolUse) e in
204
+ * PROCESSI diversi, e un lettore non deve mai vedere un file a meta'.
205
+ *
206
+ * ORDINAMENTO: due hook possono arrivare invertiti — il processo di un evento
207
+ * piu' vecchio puo' essere schedulato dopo quello di uno piu' recente. Decide
208
+ * il `ts`: un evento piu' vecchio non riporta indietro lo stato, altrimenti una
209
+ * `Stop` gia' registrata verrebbe cancellata da un `PreToolUse` in ritardo.
210
+ *
211
+ * Ritorna `true` quando il deposito riflette ALMENO questo evento — scritto, o
212
+ * superato da uno piu' recente — e `false` solo su un guasto reale. La
213
+ * differenza conta per chi chiama: `false` significa «il tuo evento non e' nel
214
+ * deposito», mai «il tuo evento era vecchio».
215
+ *
216
+ * LIMITE DICHIARATO: se il lock non e' ottenibile entro il budget — caso
217
+ * patologico, non misurato in esercizio — si scrive comunque, e allora resta la
218
+ * corsa stretta fra due scrittori (l'evento piu' recente puo' perdere). Il
219
+ * temporaneo esclusivo fa si' che anche in quel caso non si perda nessuna
220
+ * scrittura e non si veda mai un file a meta': cambia solo CHI vince.
221
+ */
222
+ function scriviStato(dirSessione, { evento, tipo, sessionId, generazione, ora = Date.now() } = {}) {
223
+ if (typeof evento !== 'string' || !evento) return false;
224
+ const dato = { event: evento, ts: ora };
225
+ if (typeof sessionId === 'string' && sessionId) dato.session_id = sessionId;
226
+ if (typeof generazione === 'string' && generazione) dato.generation = generazione;
227
+ if (typeof tipo === 'string' && tipo) dato.notification_type = tipo;
228
+ const destinazione = path.join(dirSessione, NOME_FILE);
229
+ try {
230
+ fs.mkdirSync(dirSessione, { recursive: true });
231
+ } catch (_) { return false; }
232
+ // Confronto e pubblicazione nella STESSA sezione critica: e' l'unico modo in
233
+ // cui «l'evento piu' recente vince» e' una garanzia invece di una speranza.
234
+ return conLock(dirSessione, () => {
235
+ let tmp = null;
236
+ try {
237
+ const pubblicato = tsPubblicato(dirSessione);
238
+ if (pubblicato !== null && pubblicato > ora) return true;
239
+ tmp = tmpUnivoco(dirSessione, NOME_FILE);
240
+ fs.writeFileSync(tmp, `${JSON.stringify(dato)}\n`, { encoding: 'utf8', mode: 0o600 });
241
+ fs.renameSync(tmp, destinazione);
242
+ return true;
243
+ } catch (_) {
244
+ if (tmp) { try { fs.unlinkSync(tmp); } catch (_) {} }
245
+ return false;
246
+ }
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Legge lo stato di attivita' di una sessione. Ritorna
252
+ * `{ stato, ts, sessionId, generazione }`, oppure null per assenza,
253
+ * dato scaduto, generazione superata, evento che non autorizza transizioni o
254
+ * qualsiasi rottura. Non lancia MAI.
255
+ */
256
+ function leggiAttivita(root, sessione, ora = Date.now()) {
257
+ try {
258
+ if (typeof sessione !== 'string' || !sessione) return null;
259
+ // Un nome con separatori non deve poter uscire dalla root.
260
+ if (sessione.includes('/') || sessione.includes('\\') || sessione === '..') return null;
261
+ const dir = path.join(root, sessione);
262
+ let raw;
263
+ try {
264
+ raw = fs.readFileSync(path.join(dir, NOME_FILE), 'utf8');
265
+ } catch (_) { return null; }
266
+ const dato = JSON.parse(raw);
267
+ if (!dato || typeof dato !== 'object' || Array.isArray(dato)) return null;
268
+ const ts = Number(dato.ts);
269
+ if (!Number.isFinite(ts)) return null;
270
+ if (ora - ts > MASSIMA_ETA_MS) return null;
271
+ if (ts - ora > FUTURO_TOLLERATO_MS) return null;
272
+
273
+ // Generazione: se il launcher ne ha dichiarata una e il file ne porta
274
+ // un'altra, lo stato appartiene a un lancio precedente della stessa cella.
275
+ const corrente = leggiGenerazioneCorrente(dir);
276
+ const dalFile = typeof dato.generation === 'string' && dato.generation ? dato.generation : null;
277
+ if (corrente && dalFile && corrente !== dalFile) return null;
278
+
279
+ const stato = statoDaEvento(dato.event, dato.notification_type);
280
+ if (!stato) return null;
281
+ return {
282
+ stato,
283
+ ts,
284
+ sessionId: typeof dato.session_id === 'string' ? dato.session_id : null,
285
+ generazione: dalFile,
286
+ };
287
+ } catch (_) { return null; }
288
+ }
289
+
290
+ module.exports = {
291
+ leggiAttivita, scriviStato, scriviGenerazione, statoDaEvento, daRegistrare,
292
+ NOME_FILE, NOME_GENERAZIONE, MASSIMA_ETA_MS, FUTURO_TOLLERATO_MS,
293
+ LAVORA, FERMA, ATTESA, TIPO_PERMESSO, STATO_PER_EVENTO,
294
+ };
@@ -71,17 +71,37 @@ function promptCharsOk(prompt) {
71
71
  return true;
72
72
  }
73
73
 
74
+ // Gate readiness MCP nel payload. I nomi attesi sono quelli del file
75
+ // cell-mcp materializzato (qualsiasi stringa safe <=64: la charset stretta
76
+ // vale solo sui valori che finiscono nell'opzione pane @nc_mcp_list).
77
+ function validMcpReadiness(value) {
78
+ if (value === undefined) return true;
79
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
80
+ if (Object.keys(value).some((key) => !['cwd', 'expectedServers', 'budgetMs', 'pollMs', 'cacheRoot'].includes(key))) return false;
81
+ return typeof value.cwd === 'string' && value.cwd.length > 0 && value.cwd.length <= 4096
82
+ && !/[\0\r\n]/.test(value.cwd)
83
+ && Array.isArray(value.expectedServers) && value.expectedServers.length > 0
84
+ && value.expectedServers.length <= 64
85
+ && value.expectedServers.every((n) => typeof n === 'string' && n.length >= 1
86
+ && n.length <= 64 && !/[\0\r\n]/.test(n))
87
+ && (value.budgetMs === undefined || validInteger(value.budgetMs, 0, 120000))
88
+ && (value.pollMs === undefined || validInteger(value.pollMs, 50, 5000))
89
+ && (value.cacheRoot === undefined || (typeof value.cacheRoot === 'string'
90
+ && value.cacheRoot.length > 0 && value.cacheRoot.length <= 4096 && !/[\0\r\n]/.test(value.cacheRoot)));
91
+ }
92
+
74
93
  function validRestartPrompt(value) {
75
94
  if (value === undefined) return true;
76
95
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
77
- if (Object.keys(value).some((key) => !['tmuxBin', 'tmuxSession', 'prompt', 'readyMs', 'client', 'readyWaitMs'].includes(key))) return false;
96
+ if (Object.keys(value).some((key) => !['tmuxBin', 'tmuxSession', 'prompt', 'readyMs', 'client', 'readyWaitMs', 'mcpReadiness'].includes(key))) return false;
78
97
  return typeof value.tmuxBin === 'string' && value.tmuxBin.length > 0 && value.tmuxBin.length <= 4096
79
98
  && !/[\0\r\n]/.test(value.tmuxBin)
80
99
  && typeof value.tmuxSession === 'string' && /^[\w.@%:+-]{1,128}$/.test(value.tmuxSession)
81
100
  && promptCharsOk(value.prompt)
82
101
  && (value.readyMs === undefined || validInteger(value.readyMs, 0, 30000))
83
102
  && (value.client === undefined || value.client === '' || value.client === 'kimi' || value.client === 'claude')
84
- && (value.readyWaitMs === undefined || validInteger(value.readyWaitMs, 0, 120000));
103
+ && (value.readyWaitMs === undefined || validInteger(value.readyWaitMs, 0, 120000))
104
+ && (value.mcpReadiness === undefined || validMcpReadiness(value.mcpReadiness));
85
105
  }
86
106
 
87
107
  function validIdentity(value) {
@@ -428,10 +448,36 @@ function waitChild(child) {
428
448
  // post-paste incerto (delivery-unknown / staged-not-submitted) — il main loop
429
449
  // NON auto-restarta in quel caso (leftover bytes potenzialmente residui nel PTY del
430
450
  // pane riusato; fermo bounded + restart operatore).
451
+ // Gate readiness MCP per le claude.* (config.mcpReadiness dal payload).
452
+ // La deadline e' COMPOSTA sullo stesso orologio del launch (notBeforeMs =
453
+ // avvio della GENERAZIONE corrente, mai handshake di una generazione
454
+ // precedente; budgetMs dal tetto approvato) e la cancellazione ferma il polling
455
+ // prima del respawn (isCancelled valutato ad ogni poll dalla deliver).
456
+ function buildMcpWait(config, seams) {
457
+ const mr = config && config.mcpReadiness;
458
+ if (!mr || !Array.isArray(mr.expectedServers) || mr.expectedServers.length === 0) return null;
459
+ const now = seams.nowImpl || Date.now;
460
+ const startedAt = now();
461
+ const budget = Math.max(0, Number(mr.budgetMs) || 20000);
462
+ const waitImpl = seams.waitMcpReadiness || require('./mcp-cache-readiness.js').waitMcpReadiness;
463
+ return async ({ isCancelled }) => waitImpl({
464
+ params: { cacheRoot: mr.cacheRoot, cwd: mr.cwd, expectedServers: mr.expectedServers, notBeforeMs: startedAt },
465
+ deadlineMs: startedAt + budget,
466
+ pollMs: mr.pollMs || 500,
467
+ sleepImpl: seams.sleepImpl,
468
+ nowImpl: seams.nowImpl,
469
+ isCancelled,
470
+ });
471
+ }
472
+
473
+ const MCP_DELIVERY_STATES = ['ready', 'degraded'];
474
+ const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
475
+
431
476
  function startGenerationPrompt(config, generation, childState, seams = {}) {
432
477
  if (!config) return null;
433
478
  const classified = config.client === 'kimi' || config.client === 'claude';
434
479
  if (!classified && generation === 0) return null; // legacy: gen0 resta al runtime
480
+ const mcpWait = buildMcpWait(config, seams);
435
481
  const setTimer = seams.setTimeout || setTimeout;
436
482
  const clearTimer = seams.clearTimeout || clearTimeout;
437
483
  const runTmux = seams.tmuxExec || ((bin, args, opts = {}) => new Promise((resolve) => {
@@ -444,6 +490,19 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
444
490
  try { await runTmux(config.tmuxBin, ['set-option', '-p', '-t', paneTarget, '@nc_delivery', value], {}); }
445
491
  catch (_) { /* best-effort: il report timeout di up() resta onesto */ }
446
492
  };
493
+ // Elenco bounded dei server non pronti su @nc_mcp_list (solo nomi
494
+ // validati e cap 16 per classe; muore col pane come @nc_delivery).
495
+ const markMcpList = async (mcp) => {
496
+ if (!classified || !mcp) return;
497
+ const valida = (arr) => (Array.isArray(arr) ? arr : [])
498
+ .filter((n) => typeof n === 'string' && MCP_NAME_RE.test(n)).slice(0, 16).sort();
499
+ const f = valida(mcp.failed);
500
+ const p = valida(mcp.pending);
501
+ if (!f.length && !p.length) return;
502
+ const value = `${f.length ? `failed=${f.join(',')}` : ''}${f.length && p.length ? '|' : ''}${p.length ? `pending=${p.join(',')}` : ''}`;
503
+ try { await runTmux(config.tmuxBin, ['set-option', '-p', '-t', paneTarget, '@nc_mcp_list', value], {}); }
504
+ catch (_) { /* best-effort come @nc_delivery */ }
505
+ };
447
506
  let timer = null; let cancelled = false;
448
507
  let settledDone = false; let settleResolve = null;
449
508
  const settled = new Promise((resolve) => { settleResolve = resolve; });
@@ -470,6 +529,7 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
470
529
  paneTarget: process.env.TMUX_PANE || undefined,
471
530
  readyWaitMs: config.readyWaitMs,
472
531
  isCancelled,
532
+ ...(mcpWait ? { mcpWait } : {}),
473
533
  });
474
534
  const state = result && typeof result.state === 'string' ? result.state : '';
475
535
  const uncertain = state === 'delivery-unknown' || state === 'staged-not-submitted';
@@ -480,7 +540,10 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
480
540
  }
481
541
  if (state) {
482
542
  const kind = state === 'skipped-not-ready' && result.notReady ? `:${result.notReady}` : '';
483
- await markDelivery(`${state}${kind}`);
543
+ const mcpState = result && result.mcp && MCP_DELIVERY_STATES.includes(result.mcp.state)
544
+ ? `:mcp${result.mcp.state}` : '';
545
+ await markDelivery(`${state}${kind}${mcpState}`);
546
+ if (mcpState) await markMcpList(result.mcp);
484
547
  }
485
548
  // L'esito post-paste incerto va conservato ANCHE se il child
486
549
  // era vivo al ritorno di deliver: i byte possono restare nel PTY del