@mmmbuto/nexuscrew 0.8.46 → 0.8.48

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.
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+ // Delivery del bootstrap prompt per gli engine managed Kimi (kimi.native e
3
+ // claude.kimi-code) — NexusCrew 0.8.47. SOLO questi due engine: gli altri
4
+ // managed conservano il prompt su argv (finding separato) e i custom send-keys
5
+ // conservano injectPrompt legacy senza Enter.
6
+ //
7
+ // Contratto (design audit 0.8.47):
8
+ // - Readiness REALE prima della consegna: il pane viene classificato sul SOLO
9
+ // viewport visibile (capture-pane -p, mai scrollback -S: su generation>0 lo
10
+ // scrollback puo' contenere vecchi prompt e marker stale). Auth, consenso
11
+ // custom API key e onboarding/trust sono NOT_READY: nessun paste, nessun
12
+ // Enter, la sessione resta viva e utilizzabile dall'operatore. UNKNOWN a
13
+ // timeout NON diventa mai READY: la consegna viene saltata.
14
+ // - AT-MOST-ONCE per generazione: UNA sola sequenza paste+Enter. Retry
15
+ // automatico ammesso SOLO su fallimento certo PRE-paste (resolve pane o
16
+ // load-buffer falliti): mai un secondo paste — dopo QUALUNQUE tentativo di
17
+ // paste-buffer il composer potrebbe contenere testo parziale/completo e lo
18
+ // stato e' DELIVERY_UNKNOWN/STAGED_NOT_SUBMITTED con zero secondo paste.
19
+ // Dopo un Enter OK l'esito e' 'submitted' e non esiste replay.
20
+ // - Classifier enum-only: il testo catturato non viene MAI loggato,
21
+ // persistito o incluso in risposte; esce solo lo stato bounded.
22
+ // - Recovery da catalogo costante; mai suggerire /login Anthropic per
23
+ // claude.kimi-code (il recovery corretto e' il consenso Kimi in /config).
24
+ const { tmuxExec, securePaste, resolveSessionPane, promptCharsOk, sleep } = require('./launch.js');
25
+
26
+ // Stati bounded del classifier pane. Output unico esposto all'esterno.
27
+ const PANE_STATES = Object.freeze([
28
+ 'ready', 'busy', 'not-ready-auth', 'not-ready-consent', 'not-ready-onboarding', 'unknown',
29
+ ]);
30
+
31
+ // Esiti bounded della consegna (prompt.reason in API: closed enum, G5).
32
+ const DELIVERY_STATES = Object.freeze([
33
+ 'submitted', // paste ok + pane riverificato + Enter ok
34
+ 'staged-not-submitted', // paste ok, Enter fallito: testo forse nel composer
35
+ 'delivery-unknown', // paste tentato con esito incerto / pane sparito dopo il paste
36
+ 'failed-pre-paste', // resolve/load certamente falliti (anche dopo 1 retry)
37
+ 'skipped-not-ready', // classifier not-ready a fine attesa bounded (kind in notReady)
38
+ 'skipped-unknown', // classifier mai ready entro il timeout
39
+ 'prompt-rejected', // byte di controllo nel prompt (policy §9e)
40
+ 'cancelled', // generazione terminata durante l'attesa/consegna (R3)
41
+ 'report-timeout', // up() non ha ricevuto l'esito del launcher entro il bound
42
+ ]);
43
+
44
+ // Codici bounded actionRequired (G5). Nessuna inferenza "rifiutato": il marker
45
+ // TUI prova solo che serve un'azione nel terminale della cella.
46
+ const ACTION_CODES = Object.freeze(['KIMI_AUTH_ACTION_REQUIRED', 'CLIENT_INTERACTION_REQUIRED']);
47
+
48
+ // Slug recovery bounded (R10): l'API trasporta SOLO {code, recovery}; il testo
49
+ // e' mappato localmente dalla PWA via i18n (fleet-recovery-<slug>), mai inviato
50
+ // dal server (un nodo remoto federato non deve poter iniettare testo libero).
51
+ const RECOVERY_SLUGS = Object.freeze([
52
+ 'kimi-code-consent-yes',
53
+ 'kimi-code-config-custom-api-key',
54
+ 'kimi-cli-login',
55
+ 'client-terminal-dialog',
56
+ ]);
57
+
58
+ // Marker di dialoghi MODALI full-screen: occupano la viewport, valgono su
59
+ // tutto il catturato visibile (non solo la coda).
60
+ // Live-verificati: claude 2.1.220 (consent custom API key, trust dialog).
61
+ const MODAL_NOT_READY = Object.freeze([
62
+ ['not-ready-consent', /Detected a custom API key in your environment|Do you want to use this API key\?/],
63
+ ['not-ready-onboarding', /Yes, I trust this folder|Quick safety check/],
64
+ ]);
65
+
66
+ // Marker ancorati alla CODA visibile (status bar / righe finali): lo stesso
67
+ // testo in scrollback, nel corpo conversazione o in forma diversa non deve
68
+ // classificare (R7: il marker auth e' la stringa ESATTA di status bar, non un
69
+ // "Not logged in" qualunque). Live-verificati: claude 2.1.220 (status bar
70
+ // "Not logged in · Run /login"), kimi 0.31.1 (welcome "Run /login or
71
+ // /provider", "Model: not set").
72
+ const TAIL_NOT_READY = Object.freeze([
73
+ ['not-ready-auth', /Not logged in · (?:Please run|Run) \/login\b/],
74
+ ['not-ready-auth', /Run \/login or \/provider|Model:\s+not set\b/],
75
+ ]);
76
+
77
+ // TUI occupata a processare (restart durante lavoro residuo): si attende.
78
+ const BUSY_TAIL = /esc to interrupt/i;
79
+
80
+ const TAIL_LINES = 14;
81
+
82
+ function tailOf(text) {
83
+ const lines = String(text).split('\n');
84
+ const nonEmpty = [];
85
+ for (let i = lines.length - 1; i >= 0 && nonEmpty.length < TAIL_LINES; i -= 1) {
86
+ if (lines[i].trim() !== '') nonEmpty.unshift(lines[i]);
87
+ }
88
+ return nonEmpty.join('\n');
89
+ }
90
+
91
+ // classifyPane(captured, client) -> PANE_STATES enum. Pura, senza dipendenze:
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').
94
+ function classifyPane(captured, client) {
95
+ const text = typeof captured === 'string' ? captured : '';
96
+ if (!text.trim()) return 'unknown';
97
+ for (const [state, re] of MODAL_NOT_READY) if (re.test(text)) return state;
98
+ const tail = tailOf(text);
99
+ for (const [state, re] of TAIL_NOT_READY) if (re.test(tail)) return state;
100
+ if (BUSY_TAIL.test(tail)) return 'busy';
101
+ if (client === 'kimi') {
102
+ // READY positivo Kimi: box di input corrente E modello configurato (G2).
103
+ // Il solo "Model:" non basta; la sola box non basta (presente anche da
104
+ // logged-out). Il welcome logged-out e' gia' intercettato dai marker
105
+ // not-ready qui sopra; se scrolla via senza login resta 'unknown' (safe).
106
+ const box = /^\s*│\s*>/m.test(text);
107
+ const model = /! to run a shell command/.test(tail) || /Model:\s+(?!not set\b)\S/.test(text);
108
+ return box && model ? 'ready' : 'unknown';
109
+ }
110
+ if (client === 'claude') {
111
+ // READY positivo Claude: riga prompt "❯" in coda, assenza dei marker
112
+ // not-ready (gia' valutati sopra: il cursore ❯ dei dialoghi non inganna).
113
+ return /^\s*❯/m.test(tail) ? 'ready' : 'unknown';
114
+ }
115
+ return 'unknown';
116
+ }
117
+
118
+ function clampInt(value, dflt, min, max) {
119
+ const n = Number(value);
120
+ if (!Number.isFinite(n)) return dflt;
121
+ return Math.max(min, Math.min(max, Math.trunc(n)));
122
+ }
123
+
124
+ // deliverBootstrapPrompt(opts) -> { delivered, state, notReady, attempts, reason }
125
+ // opts: { tmuxBin, session, prompt, client ('kimi'|'claude'), env,
126
+ // paneTarget (%N opzionale), readyWaitMs, pollMs, settleMs, tmpdir,
127
+ // tmuxExecImpl, captureImpl, sleepImpl, nowImpl, fsImpl, isCancelled }
128
+ // reason e' SEMPRE lo state (closed enum); notReady e' il kind bounded o ''.
129
+ // isCancelled() (R3): valutato ad OGNI poll, prima del paste e prima dell'
130
+ // Enter; se true la consegna si ferma subito ('cancelled') senza paste/Enter —
131
+ // cell-exec cancella la delivery quando la generazione termina, cosi' un
132
+ // polling in volo non puo' mai iniettare nella generazione successiva.
133
+ async function deliverBootstrapPrompt(opts = {}) {
134
+ const { tmuxBin, session, prompt } = opts;
135
+ const client = typeof opts.client === 'string' ? opts.client : '';
136
+ const env = opts.env;
137
+ const exec = opts.tmuxExecImpl || tmuxExec;
138
+ const sleepImpl = opts.sleepImpl || sleep;
139
+ const now = opts.nowImpl || Date.now;
140
+ const cancelled = typeof opts.isCancelled === 'function' ? opts.isCancelled : () => false;
141
+ const target = opts.paneTarget || `=${session}`;
142
+ const readyWaitMs = clampInt(opts.readyWaitMs, 15000, 0, 120000);
143
+ const pollMs = clampInt(opts.pollMs, 400, 50, 5000);
144
+ const settleMs = clampInt(opts.settleMs, 150, 0, 5000);
145
+ const done = (delivered, state, notReady, attempts) => ({
146
+ delivered, state, notReady, attempts, reason: state,
147
+ });
148
+ if (!promptCharsOk(prompt)) return done(false, 'prompt-rejected', '', 0);
149
+
150
+ const capture = opts.captureImpl || (async () => {
151
+ const r = await exec(tmuxBin, ['capture-pane', '-p', '-t', target], { env, timeoutMs: 2000 });
152
+ return r.err ? null : r.stdout;
153
+ });
154
+
155
+ // (1) Attesa readiness bounded: solo 'ready' positivo sblocca la consegna.
156
+ let paneState = 'unknown';
157
+ const deadline = now() + readyWaitMs;
158
+ for (;;) {
159
+ if (cancelled()) return done(false, 'cancelled', '', 0);
160
+ const text = await capture();
161
+ if (cancelled()) return done(false, 'cancelled', '', 0);
162
+ paneState = text === null ? 'unknown' : classifyPane(text, client);
163
+ if (paneState === 'ready') break;
164
+ if (now() >= deadline) {
165
+ return paneState === 'unknown'
166
+ ? done(false, 'skipped-unknown', '', 0)
167
+ : done(false, 'skipped-not-ready', paneState, 0);
168
+ }
169
+ await sleepImpl(pollMs);
170
+ }
171
+
172
+ // (2) Consegna AT-MOST-ONCE: un solo paste per generazione. Il retry copre
173
+ // SOLO fallimenti certi pre-paste (resolve/load); dopo qualsiasi tentativo
174
+ // di paste-buffer non esiste retry automatico (G1).
175
+ let attempts = 0;
176
+ for (;;) {
177
+ if (cancelled()) return done(false, 'cancelled', '', attempts);
178
+ attempts += 1;
179
+ const stage = await securePaste(tmuxBin, session, prompt, {
180
+ env, exec, target, tmpdir: opts.tmpdir, fsImpl: opts.fsImpl,
181
+ });
182
+ if (cancelled()) {
183
+ // Il paste potrebbe essere avvenuto: mai riprovare, mai Enter.
184
+ return done(false, stage.ok ? 'delivery-unknown' : 'cancelled', '', attempts);
185
+ }
186
+ if (stage.ok) {
187
+ await sleepImpl(settleMs);
188
+ if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
189
+ // Riverifica dello stesso %N sulla stessa sessione prima dell'Enter.
190
+ const paneAgain = await resolveSessionPane(tmuxBin, session, { env, exec, target });
191
+ if (paneAgain !== stage.paneId) return done(false, 'delivery-unknown', '', attempts);
192
+ if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
193
+ const enter = await exec(tmuxBin, ['send-keys', '-t', stage.paneId, 'Enter'], { env });
194
+ if (enter.err) return done(false, 'staged-not-submitted', '', attempts);
195
+ // R9: cancel con Enter appena partito -> la generazione e' morta con un
196
+ // submit in volo: esito incerto (residuo PTY possibile), mai 'submitted'.
197
+ if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
198
+ return done(true, 'submitted', '', attempts);
199
+ }
200
+ if (stage.stage === 'paste' || stage.stage === 'validate') {
201
+ // Paste tentato (esito incerto) o input rifiutato: zero secondo paste.
202
+ return done(false, stage.stage === 'validate' ? 'prompt-rejected' : 'delivery-unknown', '', attempts);
203
+ }
204
+ if (attempts >= 2) return done(false, 'failed-pre-paste', '', attempts);
205
+ // Fallimento certo PRE-paste: un solo retry, readiness riverificata prima.
206
+ await sleepImpl(pollMs);
207
+ if (cancelled()) return done(false, 'cancelled', '', attempts);
208
+ const text = await capture();
209
+ const again = text === null ? 'unknown' : classifyPane(text, client);
210
+ if (again !== 'ready') return done(false, 'failed-pre-paste', '', attempts);
211
+ }
212
+ }
213
+
214
+ // waitDeliveryReport(tmuxBin, target, opts) -> delivery-like | null (R2).
215
+ // Il launcher supervisionato (cell-exec) e' l'UNICO owner della consegna per
216
+ // TUTTE le generazioni degli engine Kimi e pubblica l'esito bounded sull'
217
+ // opzione tmux di pane @nc_delivery ('<state>' o '<state>:<notReady>', solo
218
+ // enum chiusi; muore col pane, nessuno state file). up() la legge con attesa
219
+ // bounded: niente paste dal runtime, niente doppio writer cross-generation.
220
+ async function waitDeliveryReport(tmuxBin, target, { env, exec, sleepImpl, nowImpl, timeoutMs, pollMs } = {}) {
221
+ const run = exec || tmuxExec;
222
+ const sleepFn = sleepImpl || sleep;
223
+ const now = nowImpl || Date.now;
224
+ const deadline = now() + clampInt(timeoutMs, 18000, 100, 150000);
225
+ const step = clampInt(pollMs, 300, 50, 5000);
226
+ for (;;) {
227
+ const r = await run(tmuxBin,
228
+ ['display-message', '-p', '-t', target, '#{@nc_delivery}'],
229
+ { env, timeoutMs: 2000 });
230
+ const raw = r.err ? '' : r.stdout.trim();
231
+ if (raw) {
232
+ const [state, notReady = ''] = raw.split(':');
233
+ if (!DELIVERY_STATES.includes(state)) return null; // valore non bounded: mai fidarsi
234
+ const kind = PANE_STATES.includes(notReady) ? notReady : '';
235
+ return {
236
+ delivered: state === 'submitted',
237
+ state, notReady: state === 'skipped-not-ready' ? kind : '',
238
+ attempts: 0, reason: state,
239
+ };
240
+ }
241
+ if (now() >= deadline) return null;
242
+ await sleepFn(step);
243
+ }
244
+ }
245
+
246
+ // actionRequiredFor(client, provider, delivery) -> null | { code, recovery }
247
+ // Mappa bounded delivery -> azione operatore. Solo skip per not-ready/unknown
248
+ // producono actionRequired; i fallimenti di trasporto restano nel prompt.state.
249
+ // R10: SOLO {code, recovery} closed enum/slug — il testo e' i18n locale PWA.
250
+ function actionRequiredFor(client, provider, delivery) {
251
+ if (!delivery || delivery.delivered) return null;
252
+ if (delivery.state !== 'skipped-not-ready' && delivery.state !== 'skipped-unknown') return null;
253
+ const kind = delivery.notReady;
254
+ let recovery = 'client-terminal-dialog';
255
+ let code = 'CLIENT_INTERACTION_REQUIRED';
256
+ if (provider === 'kimi-code') {
257
+ code = kind === 'not-ready-consent' || kind === 'not-ready-auth'
258
+ ? 'KIMI_AUTH_ACTION_REQUIRED' : 'CLIENT_INTERACTION_REQUIRED';
259
+ recovery = kind === 'not-ready-consent' ? 'kimi-code-consent-yes'
260
+ : kind === 'not-ready-auth' ? 'kimi-code-config-custom-api-key'
261
+ : 'client-terminal-dialog';
262
+ } else if (client === 'kimi') {
263
+ if (kind === 'not-ready-auth') {
264
+ code = 'KIMI_AUTH_ACTION_REQUIRED';
265
+ recovery = 'kimi-cli-login';
266
+ }
267
+ }
268
+ if (!ACTION_CODES.includes(code) || !RECOVERY_SLUGS.includes(recovery)) return null;
269
+ return { code, recovery };
270
+ }
271
+
272
+ module.exports = {
273
+ PANE_STATES, DELIVERY_STATES, ACTION_CODES, RECOVERY_SLUGS,
274
+ classifyPane, deliverBootstrapPrompt, waitDeliveryReport, actionRequiredFor,
275
+ };
@@ -25,6 +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
29
 
29
30
  // TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
30
31
  const STATUS_TTL_MS = 2000;
@@ -92,7 +93,9 @@ function createBuiltinRuntime(ctx) {
92
93
  ? 'standard'
93
94
  : (remembered || engineDefault || '');
94
95
  return {
95
- cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
96
+ // `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
97
+ // leggibile e viaggia accanto, senza mai sostituirlo.
98
+ cell: c.id, label: c.label || '', tmuxSession: c.tmuxSession, engine: c.engine,
96
99
  model: c.model || '', models: { ...(c.models || {}) },
97
100
  permissionPolicy: effectivePolicy,
98
101
  permissionPolicies: { ...(c.permissionPolicies || {}) },
@@ -209,6 +212,12 @@ function createBuiltinRuntime(ctx) {
209
212
  tmuxSession: cell.tmuxSession,
210
213
  prompt: cell.prompt,
211
214
  readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
215
+ // Solo gli engine managed Kimi (kimi.native / claude.kimi-code) hanno
216
+ // la delivery classificata at-most-once ai restart; gli engine custom
217
+ // send-keys conservano il reinject legacy senza Enter (0.8.47 G4).
218
+ client: engine.managed && engine.managed.client === 'kimi' ? 'kimi'
219
+ : (engine.managed && engine.managed.provider === 'kimi-code' ? 'claude' : ''),
220
+ readyWaitMs: Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000)),
212
221
  },
213
222
  } : {}),
214
223
  });
@@ -396,20 +405,52 @@ function createBuiltinRuntime(ctx) {
396
405
  };
397
406
  }
398
407
 
399
- // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
400
- // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
408
+ // (6) prompt: due percorsi distinti (0.8.47, R2 single-owner).
409
+ // - managed kimi.native / claude.kimi-code: la consegna e' posseduta SOLO
410
+ // dal supervisore (cell-exec) per TUTTE le generazioni; qui si legge
411
+ // l'esito bounded (@nc_delivery sul pane) con attesa bounded. Mai paste/
412
+ // Enter dal runtime: niente doppia delivery se gen0 muore durante
413
+ // l'attesa e gen1 parte sotto il supervisore.
414
+ // - engine custom promptMode 'send-keys': injectPrompt legacy (bracketed
415
+ // paste SENZA Enter, contratto invariato — G4), target %N esatto.
401
416
  let prompt = null;
417
+ let actionRequired = null;
402
418
  if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
403
- const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
404
- prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
405
- env: minimalEnv(),
406
- readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : readyMs,
407
- target,
408
- engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
409
- });
419
+ const managedClient = engine.managed && typeof engine.managed.client === 'string' ? engine.managed.client : '';
420
+ const managedProvider = engine.managed && typeof engine.managed.provider === 'string' ? engine.managed.provider : '';
421
+ const classified = managedClient === 'kimi' || managedProvider === 'kimi-code';
422
+ if (classified) {
423
+ const readyWaitMs = Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000));
424
+ const reportWaitMs = Math.max(100, Math.min(150000,
425
+ (Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + 2000));
426
+ const report = await waitDeliveryReport(tmuxBin,
427
+ paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`,
428
+ { env: minimalEnv(), timeoutMs: reportWaitMs });
429
+ const delivery = report || {
430
+ delivered: false, state: 'report-timeout', notReady: '', attempts: 0, reason: 'report-timeout',
431
+ };
432
+ prompt = {
433
+ injected: delivery.delivered,
434
+ delivered: delivery.delivered,
435
+ state: delivery.state,
436
+ reason: delivery.reason,
437
+ };
438
+ actionRequired = actionRequiredFor(managedClient, managedProvider, delivery);
439
+ } else {
440
+ const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
441
+ prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
442
+ env: minimalEnv(),
443
+ readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : readyMs,
444
+ target,
445
+ engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
446
+ });
447
+ }
410
448
  }
411
449
  cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
412
- return { ok: true, cell: cellId, session: cell.tmuxSession, prompt };
450
+ return {
451
+ ok: true, cell: cellId, session: cell.tmuxSession, prompt,
452
+ ...(actionRequired ? { actionRequired } : {}),
453
+ };
413
454
  }
414
455
 
415
456
  async function down(cellId /* , opts */) {
package/lib/mcp/cells.js CHANGED
@@ -8,6 +8,9 @@
8
8
  // l'astrazione `ctx.api` (loopback + Bearer del bridge); ACL e identita'
9
9
  // owner-qualified sono applicate lato server HTTP.
10
10
  const { isValidSession } = require('../files/store.js');
11
+ // Stesso validatore usato in uscita: una label federata non deve poter entrare
12
+ // qui in una forma che noi non produrremmo mai.
13
+ const { safeCellLabel } = require('../cells/routes.js');
11
14
 
12
15
  const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
13
16
  const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
@@ -109,6 +112,14 @@ function normalizeCellPayload(payload, owner, callerSession = null) {
109
112
  owner: owner.label,
110
113
  route,
111
114
  cell: raw.cell,
115
+ // `cell` indirizza, `label` si legge. Un nodo che chiama la propria cella
116
+ // come il motore la esporrebbe cosi' a tutta la rete: senza un nome
117
+ // separato chi la riceve non ha modo di sapere che ruolo occupa.
118
+ // La label di una cella REMOTA e' testo auto-dichiarato: si delimita con
119
+ // lo stesso validatore usato in uscita, e si marca come riferita perche'
120
+ // chi legge distingua cio' che sappiamo da cio' che ci e' stato detto.
121
+ label: safeCellLabel(raw.label),
122
+ labelReported: owner.route.length > 0,
112
123
  tmuxSession: raw.tmuxSession,
113
124
  engine: typeof raw.engine === 'string' ? raw.engine : '',
114
125
  model: typeof raw.model === 'string' ? raw.model : '',
package/lib/mcp/tools.js CHANGED
@@ -260,7 +260,7 @@ const TOOLS = [
260
260
  if (f && f.available) {
261
261
  fleet = {
262
262
  cells: (Array.isArray(f.cells) ? f.cells : []).map((c) => ({
263
- cell: c.cell, session: c.tmuxSession, engine: c.engine, active: !!c.active,
263
+ cell: c.cell, label: c.label || '', session: c.tmuxSession, engine: c.engine, active: !!c.active,
264
264
  })),
265
265
  };
266
266
  }
@@ -42,6 +42,11 @@ function routedPeer(entry) {
42
42
  const route = Array.isArray(entry.route) ? [...entry.route] : [];
43
43
  return {
44
44
  name: entry.name,
45
+ // Riferita dal nodo che ce l'ha inoltrata, non verificata da noi: si mostra
46
+ // perche' senza di essa un nodo in transito e' solo uno slug, ma resta un
47
+ // dato di seconda mano e non sostituisce l'instanceId.
48
+ label: typeof entry.label === 'string' ? entry.label : '',
49
+ labelReported: true,
45
50
  nodeId: entry.instanceId,
46
51
  instanceId: entry.instanceId,
47
52
  kind: 'transitive',
@@ -75,4 +75,25 @@ function quarantineSlot(pool, { slot, now = Date.now() } = {}) {
75
75
  return next;
76
76
  }
77
77
 
78
- module.exports = { LEASE_MS, GRACE_MS, nextReadySlot, prepareRotation, abortPrepared, commitRotation, settleGrace, quarantineSlot };
78
+
79
+ // Esito di uno spegnimento che puo' riguardare PIU' entry insieme: durante la
80
+ // grace di una rotazione lo slot vecchio e quello nuovo coesistono. Una
81
+ // chiusura riuscita su una sola non e' una chiusura: dichiararla tale
82
+ // lascerebbe l'altra viva mentre chi chiama registra il canale come privato.
83
+ // `no pidfile` e `stale (pid dead)` contano come spente: li' non e' rimasto
84
+ // nulla di vivo da attribuire.
85
+ const STOP_ALREADY_GONE = ['no pidfile', 'stale (pid dead)'];
86
+
87
+ function stopWasDemonstrated(result) {
88
+ if (!result) return false;
89
+ return result.stopped === true || STOP_ALREADY_GONE.includes(result.reason);
90
+ }
91
+
92
+ function summarizeStops(results) {
93
+ const list = Array.isArray(results) ? results : [];
94
+ const stoppedAny = list.some(stopWasDemonstrated);
95
+ const quarantinedAny = list.some((result) => !stopWasDemonstrated(result));
96
+ return { stoppedAny, quarantinedAny, allClosed: stoppedAny && !quarantinedAny };
97
+ }
98
+
99
+ module.exports = { LEASE_MS, GRACE_MS, nextReadySlot, prepareRotation, abortPrepared, commitRotation, settleGrace, quarantineSlot, stopWasDemonstrated, summarizeStops };
@@ -23,8 +23,16 @@ function close(server) {
23
23
  });
24
24
  }
25
25
 
26
- function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics } = {}) {
26
+ function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics, attachUpgrade } = {}) {
27
27
  if (typeof app !== 'function') throw new Error('reverse slot listeners richiede app HTTP');
28
+ // Fail-closed: servire `app` non basta: il routing degli upgrade WS vive
29
+ // sull'istanza `server`, non sull'app Express. Un listener senza handler di
30
+ // upgrade e' HTTP-only e degrada in silenzio (la SPA risponde 200 a un
31
+ // upgrade). Obbligare la dipendenza rende impossibile ricreare il difetto
32
+ // per dimenticanza, invece di affidarsi a un test che enumera gli ingressi.
33
+ if (typeof attachUpgrade !== 'function') {
34
+ throw new Error('reverse slot listeners richiede attachUpgrade');
35
+ }
28
36
  const listeners = new Map(); // local target port -> owned server + immutable expected tuple
29
37
 
30
38
  async function open({ nodeName, remotePort, generation, instanceId, secret }) {
@@ -33,6 +41,7 @@ function createReverseSlotListeners({ app, createServerImpl = http.createServer,
33
41
  }
34
42
  if (typeof secret !== 'string' || !secret) throw new Error('reverse slot listener credential mancante');
35
43
  const server = createServerImpl(app);
44
+ attachUpgrade(server);
36
45
  let address;
37
46
  try { address = await listen(server, { host: '127.0.0.1', port: 0, exclusive: true }); }
38
47
  catch (error) { try { server.close(); } catch (_) {} throw error; }
@@ -96,6 +96,16 @@ async function probeReverseSlot({ port, secret, expected, fetchImpl = fetch, tim
96
96
  const response = await fetchImpl(`http://127.0.0.1:${port}/reverse-slot-proof`, {
97
97
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(probe), signal: ctrl.signal,
98
98
  });
99
+ // Un 409 e' la RISPOSTA di un listener corretto che ha valutato la tupla e
100
+ // l'ha rifiutata: e' un esito, non un'assenza. Collassarlo in
101
+ // "non ottenuta" lo renderebbe indistinguibile da un timeout, e chi
102
+ // classifica gli errori tratterebbe come transitorio un mismatch reale.
103
+ if (response && response.status === 409) {
104
+ const failure = await response.json().catch(() => null);
105
+ const code = failure && typeof failure.code === 'string' && /^[a-z0-9-]{1,64}$/.test(failure.code)
106
+ ? failure.code : 'reverse-slot-proof-mismatch';
107
+ return { owned: false, code };
108
+ }
99
109
  if (!response || response.status !== 200) return { owned: false, code: 'reverse-slot-proof-unavailable' };
100
110
  const body = await response.json().catch(() => null);
101
111
  return verifySlotProof({ secret, expected, challenge: probe, response: body });
@@ -17,13 +17,22 @@ function defaultPath(home = os.homedir()) {
17
17
  function parseEntry(raw) {
18
18
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
19
19
  const keys = Object.keys(raw).sort();
20
- if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route'].includes(k))) return null;
20
+ if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route', 'label'].includes(k))) return null;
21
21
  if (!NODE_ID_RE.test(raw.instanceId) || !NODE_NAME_RE.test(raw.name)) return null;
22
22
  if (!Array.isArray(raw.route) || raw.route.length < 2 || raw.route.length > MAX_HOPS) return null;
23
23
  if (raw.route.some((x) => !NODE_NAME_RE.test(x)) || new Set(raw.route).size !== raw.route.length) return null;
24
24
  if (raw.name !== raw.route[raw.route.length - 1]) return null;
25
25
  if (!Number.isInteger(raw.lastSeen) || raw.lastSeen < 0) return null;
26
- return { instanceId: raw.instanceId, name: raw.name, route: [...raw.route], lastSeen: raw.lastSeen };
26
+ // La label persiste solo se rispetta la stessa forma accettata altrove: un
27
+ // file di cache e' un ingresso come un altro e non deve poter reintrodurre
28
+ // testo che non produrremmo mai.
29
+ const label = typeof raw.label === 'string' && raw.label.trim()
30
+ && raw.label.trim().length <= 64 && !/[\x00-\x1f\x7f]/.test(raw.label.trim())
31
+ ? raw.label.trim() : null;
32
+ return {
33
+ instanceId: raw.instanceId, name: raw.name, route: [...raw.route],
34
+ ...(label ? { label } : {}), lastSeen: raw.lastSeen,
35
+ };
27
36
  }
28
37
 
29
38
  function parseCache(raw) {