@mmmbuto/nexuscrew 0.9.41 → 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.
@@ -0,0 +1,253 @@
1
+ 'use strict';
2
+ // Canale di attivita' per le celle codex e codex-vl.
3
+ //
4
+ // Perche' esiste: le celle Claude pubblicano il proprio stato di turno con gli
5
+ // hook di Claude Code (bin/nc-activity-hook.js, iniettato via --settings). Gli
6
+ // engine codex non hanno quel meccanismo, quindi fino a oggi la UI le mostrava
7
+ // «non verificate» — che era la verita', ma anche un'informazione che si poteva
8
+ // avere. Codex ha un sistema di hook proprio, con una differenza che conta:
9
+ // un hook iniettato da riga di comando NON e' fidato finche' il suo hash non
10
+ // compare in `hooks.state`, altrimenti il client apre un dialogo di revisione e
11
+ // non lo esegue.
12
+ //
13
+ // Questo modulo costruisce i due pezzi che servono, e nient'altro:
14
+ // 1. gli argomenti `-c hooks.<Evento>=[...]` che DEFINISCONO l'hook;
15
+ // 2. gli argomenti `-c hooks.state={...}` che lo FIDANO, con l'hash che il
16
+ // client si aspetta di trovare.
17
+ //
18
+ // L'hash non e' lo sha256 del testo del comando: e' lo sha256 del JSON CANONICO
19
+ // di un'identita' normalizzata (evento + handler normalizzato). Replicato da
20
+ // codex-rs/hooks/src/engine/discovery.rs (`hook_hash`) e
21
+ // codex-rs/config/src/fingerprint.rs (`version_for_toml`).
22
+ //
23
+ // La forma `-c hooks.state.<chiave>.trusted_hash=...` NON funziona: il parser
24
+ // di `-c` spezza il path sui punti (codex-rs/config/src/overrides.rs) e la
25
+ // chiave dell'hook ne contiene. Percio' `hooks.state` si passa come TABELLA
26
+ // inline. Verificato in una prova isolata su TUI reale, su entrambe le
27
+ // versioni in uso.
28
+
29
+ const crypto = require('node:crypto');
30
+ const { termuxRuntimePaths } = require('../runtime/env.js');
31
+
32
+ // Etichette usate nelle chiavi di stato — codex-rs/hooks/src/lib.rs,
33
+ // `hook_event_key_label`. Solo gli eventi che iniettiamo.
34
+ const ETICHETTA_EVENTO = Object.freeze({
35
+ PreToolUse: 'pre_tool_use',
36
+ PostToolUse: 'post_tool_use',
37
+ SessionStart: 'session_start',
38
+ SessionEnd: 'session_end',
39
+ UserPromptSubmit: 'user_prompt_submit',
40
+ Stop: 'stop',
41
+ Interrupt: 'interrupt',
42
+ });
43
+
44
+ // Timeout normalizzato — codex-rs/hooks/src/engine/discovery.rs,
45
+ // `normalize_command_hook`: dieci minuti per tutti, UN SECONDO per SessionEnd e
46
+ // Interrupt (che hanno anche un tetto di tre). Il valore entra nell'hash,
47
+ // quindi sbagliarlo qui farebbe ricomparire il dialogo di revisione.
48
+ const TIMEOUT_STANDARD_SEC = 600;
49
+ const TIMEOUT_FINE_SEC = 1;
50
+
51
+ // La fonte sintetica da cui il client crede che l'hook provenga: e' il layer
52
+ // `SessionFlags`, cioe' proprio cio' che passiamo con `-c`
53
+ // (discovery.rs:402-422).
54
+ const KEY_SOURCE_SESSION_FLAGS = '/<session-flags>/config.toml';
55
+
56
+ function etichetta(evento) {
57
+ const label = ETICHETTA_EVENTO[evento];
58
+ if (!label) throw new Error(`evento hook non gestito: ${evento}`);
59
+ return label;
60
+ }
61
+
62
+ function timeoutPerEvento(evento) {
63
+ return evento === 'SessionEnd' || evento === 'Interrupt'
64
+ ? TIMEOUT_FINE_SEC
65
+ : TIMEOUT_STANDARD_SEC;
66
+ }
67
+
68
+ // Riordina le chiavi in modo ricorsivo: `version_for_toml` serializza il JSON
69
+ // CANONICO, e due identita' con le stesse chiavi in ordine diverso devono dare
70
+ // lo stesso hash.
71
+ function ordinaChiavi(valore) {
72
+ if (Array.isArray(valore)) return valore.map(ordinaChiavi);
73
+ if (valore && typeof valore === 'object') {
74
+ const out = {};
75
+ for (const chiave of Object.keys(valore).sort()) out[chiave] = ordinaChiavi(valore[chiave]);
76
+ return out;
77
+ }
78
+ return valore;
79
+ }
80
+
81
+ function jsonCanonico(valore) {
82
+ return JSON.stringify(ordinaChiavi(valore));
83
+ }
84
+
85
+ /**
86
+ * Hash atteso da codex per questo handler su questo evento.
87
+ * Replica `hook_hash` -> `version_for_toml`.
88
+ */
89
+ function hashHook(evento, comando) {
90
+ const identita = {
91
+ event_name: etichetta(evento),
92
+ hooks: [{
93
+ async: false,
94
+ command: comando,
95
+ timeout: timeoutPerEvento(evento),
96
+ type: 'command',
97
+ }],
98
+ };
99
+ const digest = crypto.createHash('sha256').update(jsonCanonico(identita), 'utf8').digest('hex');
100
+ return `sha256:${digest}`;
101
+ }
102
+
103
+ /** Chiave dello stato per un handler: `hook_key` (hooks/src/lib.rs). */
104
+ function chiaveHook(evento, gruppo = 0, handler = 0) {
105
+ return `${KEY_SOURCE_SESSION_FLAGS}:${etichetta(evento)}:${gruppo}:${handler}`;
106
+ }
107
+
108
+ // Stringa TOML. I valori qui sono percorsi e comandi unix, senza backslash:
109
+ // JSON.stringify produce una basic string corretta per questi contenuti.
110
+ function tomlString(valore) {
111
+ return JSON.stringify(String(valore));
112
+ }
113
+
114
+ /** Definizione di UN hook: `hooks.<Evento>=[{hooks=[{type,command}]}]`. */
115
+ function definizioneHook(evento, comando) {
116
+ return `hooks.${evento}=[{hooks=[{type="command",command=${tomlString(comando)}}]}]`;
117
+ }
118
+
119
+ /** `hooks.state` come tabella inline con un trusted_hash per chiave. */
120
+ function tabellaStato(voci) {
121
+ const parti = voci.map(({ chiave, hash }) => `${tomlString(chiave)}={trusted_hash=${tomlString(hash)}}`);
122
+ return `hooks.state={${parti.join(',')}}`;
123
+ }
124
+
125
+ /**
126
+ * Argomenti da appendere alla riga di lancio di una cella codex/codex-vl.
127
+ * `comandoPerEvento(evento)` ritorna il comando dell'hook per quell'evento.
128
+ * Ritorna [] se non c'e' niente da iniettare.
129
+ */
130
+ function argomentiHookCodex(eventi, comandoPerEvento) {
131
+ if (!Array.isArray(eventi) || eventi.length === 0) return [];
132
+ const args = [];
133
+ const voci = [];
134
+ for (const evento of eventi) {
135
+ const comando = comandoPerEvento(evento);
136
+ if (!comando) continue;
137
+ args.push('-c', definizioneHook(evento, comando));
138
+ voci.push({ chiave: chiaveHook(evento), hash: hashHook(evento, comando) });
139
+ }
140
+ if (voci.length === 0) return [];
141
+ args.push('-c', tabellaStato(voci));
142
+ return args;
143
+ }
144
+
145
+ // ── Guardia di versione ────────────────────────────────────────────────
146
+ //
147
+ // L'hash dipende dalla normalizzazione, dalla serializzazione e dal source
148
+ // path sintetico di UNA versione di codex. Se il binario cambia quelle regole,
149
+ // l'hash non combacia piu', il client apre il dialogo «Hooks need review» e la
150
+ // cella resta BLOCCATA su una domanda che nessuno vede. Il costo di sbagliare
151
+ // non e' «hook che non parte»: e' una cella che non lavora.
152
+ //
153
+ // Quindi si inietta SOLO su versioni effettivamente provate, e su ogni altra
154
+ // non si inietta niente: la cella torna «non verificato» come oggi, che e' il
155
+ // comportamento di prima — un degrado dichiarato, non un guasto introdotto.
156
+ const VERSIONI_PROVATE = Object.freeze({
157
+ codex: ['0.156.1'],
158
+ 'codex-vl': ['0.155.1'],
159
+ });
160
+
161
+ // Probe della versione, con lo stesso seam del ramo vl (cfg.vlVersionProbe):
162
+ // i test non devono dipendere da un binario vero. Ritorna l'output GREZZO.
163
+ // `binary` e' il percorso RISOLTO del binario della cella, non il nome del
164
+ // client: si esegue quello, senza shell e con un tetto di tempo breve.
165
+ function versionOutput(binary, cfg) {
166
+ const probe = cfg && typeof cfg.codexVersionProbe === 'function' ? cfg.codexVersionProbe : null;
167
+ if (probe) {
168
+ const out = probe(binary);
169
+ return out === null || out === undefined ? null : String(out);
170
+ }
171
+ const { spawnSync } = require('node:child_process');
172
+ const r = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: 3000, shell: false });
173
+ if (r.error || r.status !== 0) return null;
174
+ return `${r.stdout || ''}${r.stderr || ''}`;
175
+ }
176
+
177
+ /**
178
+ * Si puo' iniettare su questo binario? Ritorna { ok, versione } oppure
179
+ * { ok: false, reason } — il motivo e' per la riga di log, mai per l'utente.
180
+ * `client` sceglie l'elenco delle versioni provate; `binary` e' cio' che si
181
+ * esegue davvero. Sono due cose diverse: se coincidono e' una coincidenza.
182
+ */
183
+ function gateVersione(client, binary, cfg) {
184
+ const ammesse = VERSIONI_PROVATE[client];
185
+ if (!ammesse) return { ok: false, reason: `client ${client} non gestito dal canale hook` };
186
+ if (!binary) {
187
+ return { ok: false, reason: `binario di ${client} non risolto sul nodo: nessun hook iniettato` };
188
+ }
189
+ const out = versionOutput(binary, cfg);
190
+ if (out === null) {
191
+ return { ok: false, reason: `versione di ${client} non determinabile (${binary} --version non risponde): nessun hook iniettato` };
192
+ }
193
+ const m = out.match(/(\d+\.\d+\.\d+)/);
194
+ if (!m) {
195
+ return { ok: false, reason: `versione di ${client} non riconosciuta da "${String(out).trim().slice(0, 60)}": nessun hook iniettato` };
196
+ }
197
+ if (!ammesse.includes(m[1])) {
198
+ return { ok: false, reason: `versione ${client} ${m[1]} non provata con gli hook (provate: ${ammesse.join(', ')}): nessun hook iniettato` };
199
+ }
200
+ return { ok: true, versione: m[1] };
201
+ }
202
+
203
+ // La chiave di fiducia di questi hook e' Unix: `key_source` e'
204
+ // `/<session-flags>/config.toml` e il comando e' quotato in stile POSIX. Il
205
+ // sorgente di codex sintetizza quella fonte con `C:\` su Windows
206
+ // (`hooks/src/engine/discovery.rs:402-435`), quindi la' la chiave che il
207
+ // client calcola NON coincide con quella che generiamo: si inietterebbe un
208
+ // hook non fidato, cioe' esattamente il dialogo che la guardia esiste per
209
+ // evitare. Finche' non c'e' una prova su Windows, li' non si inietta.
210
+ //
211
+ // Termux e' un caso diverso e piu' stretto: il probe esegue il binario
212
+ // direttamente, mentre il launcher lo lancia tramite Node quando lo shim ha
213
+ // uno shebang senza `/usr/bin/env` (`managed.js:1006-1028`). Il gate
214
+ // fallirebbe chiuso, e una cella «non verificata» senza motivo e' un limite
215
+ // che si dichiara qui invece di lasciarlo scoprire.
216
+ function gatePiattaforma(cfg) {
217
+ const platform = (cfg && cfg.platform) || process.platform;
218
+ const env = (cfg && cfg.env) || process.env;
219
+ const termux = platform === 'android'
220
+ || termuxRuntimePaths(env, { platform, home: cfg && cfg.home }) !== null;
221
+ if (termux) {
222
+ return { ok: false, reason: 'termux: il probe della versione non segue il percorso di lancio' };
223
+ }
224
+ if (platform !== 'linux' && platform !== 'darwin') {
225
+ return { ok: false, reason: `piattaforma ${platform}: chiave di fiducia e quoting degli hook non provati qui` };
226
+ }
227
+ return { ok: true };
228
+ }
229
+
230
+ // Eventi iniettati per una cella codex, nell'ordine in cui il client li emette.
231
+ const EVENTI_CODEX = Object.freeze([
232
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt', 'SessionEnd',
233
+ ]);
234
+
235
+ module.exports = {
236
+ ETICHETTA_EVENTO,
237
+ TIMEOUT_STANDARD_SEC,
238
+ TIMEOUT_FINE_SEC,
239
+ KEY_SOURCE_SESSION_FLAGS,
240
+ VERSIONI_PROVATE,
241
+ EVENTI_CODEX,
242
+ etichetta,
243
+ timeoutPerEvento,
244
+ jsonCanonico,
245
+ hashHook,
246
+ chiaveHook,
247
+ definizioneHook,
248
+ tabellaStato,
249
+ argomentiHookCodex,
250
+ versionOutput,
251
+ gateVersione,
252
+ gatePiattaforma,
253
+ };
@@ -10,6 +10,7 @@ const { execFile } = require('node:child_process');
10
10
  const { ENV_KEY_RE } = require('./env-key.js');
11
11
  const { termuxRuntimePaths } = require('../runtime/env.js');
12
12
  const { readCredentialStore, safePrivateDir } = require('./credentials.js');
13
+ const { argomentiHookCodex, gateVersione, gatePiattaforma, EVENTI_CODEX } = require('./codex-hooks.js');
13
14
 
14
15
  const OLLAMA_CLOUD_MODELS = Object.freeze([
15
16
  'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
@@ -1621,9 +1622,13 @@ function cellMcpDeny(home, cell) {
1621
1622
  // un solo token composto, non due sorgenti distinte. Dove quel token oggi non
1622
1623
  // esiste (ramo strict) ne nasce uno che porta SOLO gli hook.
1623
1624
  //
1624
- // Solo il client `claude` ha questi eventi. Gli altri motori (codex-vl, pi,
1625
- // shell) restano senza canale: la UI li mostra «non verificati», che e' cio'
1626
- // che si sa di loro. Non si inventa uno stato che nessuno pubblica.
1625
+ // Il client `claude` li riceve via `--settings` (sotto). Il client codex e
1626
+ // codex-vl li riceve in forma `-c` (lib/fleet/codex-hooks.js), con la stessa
1627
+ // matrice evento->stato e lo stesso scrittore — e li riceve SOLO su versioni
1628
+ // provate, perche' la fiducia dell'hook dipende dall'hash di quella versione.
1629
+ // Gli altri motori (pi, shell) restano senza canale: la UI li mostra «non
1630
+ // verificati», che e' cio' che si sa di loro. Non si inventa uno stato che
1631
+ // nessuno pubblica.
1627
1632
 
1628
1633
  // Gli eventi REGISTRATI, quelli misurati su 2.1.280 nel Gate A. `StopFailure`
1629
1634
  // non compare perche' su quella versione NON esiste: registrarlo sarebbe una
@@ -1683,6 +1688,14 @@ function cellActivityHooks(cell, cfg) {
1683
1688
  const dirSessione = path.join(root, cell.tmuxSession);
1684
1689
  const generazione = cfg && typeof cfg.activityGeneration === 'string' && cfg.activityGeneration
1685
1690
  ? cfg.activityGeneration : null;
1691
+ // QUI NON SI SCRIVE NIENTE SU DISCO. La generazione viaggia negli argv degli
1692
+ // hook e nel payload del broker; a scriverla e' `cell-exec`, che gira DENTRO
1693
+ // la sessione tmux appena creata — quindi solo per il vincitore della
1694
+ // new-session — e lo fa prima di avviare il client (cell-exec.js: la
1695
+ // generazione su disco e' quella del client che gira davvero). Scriverla qui
1696
+ // significava scriverla anche per chi stava per perdere la corsa: sul disco
1697
+ // restava la generazione del perdente, che non e' quella di nessun lancio
1698
+ // vivo, e ogni evento del vincitore veniva scartato al lettore.
1686
1699
  const hooks = {};
1687
1700
  for (const evento of EVENTI_ATTIVITA) {
1688
1701
  const voce = { hooks: [{ type: 'command', command: comandoHook(evento, script, dirSessione, generazione) }] };
@@ -1694,7 +1707,9 @@ function cellActivityHooks(cell, cfg) {
1694
1707
  if (evento === 'PreToolUse' || evento === 'PostToolUse') voce.matcher = '*';
1695
1708
  hooks[evento] = [voce];
1696
1709
  }
1697
- return { hooks, dirSessione };
1710
+ // `script` e `generazione` escono insieme agli hooks perche' servono anche al
1711
+ // canale codex, che ricompone lo stesso comando in forma `-c`.
1712
+ return { hooks, dirSessione, script, generazione };
1698
1713
  }
1699
1714
 
1700
1715
  // L'argomento `--settings` della cella: deny MCP e hook in UN SOLO oggetto.
@@ -2496,6 +2511,40 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2496
2511
  if (attivita) args.push(`--settings=${JSON.stringify({ hooks: attivita.hooks })}`);
2497
2512
  }
2498
2513
  } else if (spec.client === 'codex' || spec.client === 'codex-vl') {
2514
+ // Canale di attivita' per le celle codex: gli stessi eventi che le celle
2515
+ // Claude pubblicano via `--settings`, qui come hook `-c` con la fiducia che
2516
+ // il client pretende (senza, apre un dialogo di revisione e la cella resta
2517
+ // ferma su una domanda). Vince SOLO su versioni provate: su ogni altra non
2518
+ // si inietta niente e la cella torna «non verificato» come prima — un
2519
+ // degrado dichiarato, mai un dialogo che blocca il turno.
2520
+ {
2521
+ // `attivita` e' dichiarata nel ramo claude e qui non e' in scope: si
2522
+ // ricalcola. E' pura e non scrive niente su disco: calcola gli stessi
2523
+ // argv (stessa generazione) per il client codex.
2524
+ const attivita = cellActivityHooks(cell, cfg);
2525
+ // Il gate riceve il binario RISOLTO per questa cella (`info.binary`,
2526
+ // lo stesso che finisce in argv a fine funzione), non il nome del
2527
+ // client: col nome si interrogherebbe il PATH del processo, che puo'
2528
+ // essere un eseguibile DIVERSO da quello che la cella lancera'. La
2529
+ // guardia esiste per non far bloccare la cella su un dialogo di
2530
+ // revisione, e una guardia che guarda un altro binario da' il via
2531
+ // libero sbagliato: e' il caso che deve impedire.
2532
+ const piattaforma = gatePiattaforma(cfg);
2533
+ const gate = piattaforma.ok ? gateVersione(spec.client, info.binary, cfg) : piattaforma;
2534
+ if (!gate.ok) {
2535
+ if (typeof cfg.log === 'function') cfg.log(`hook codex non iniettati: ${gate.reason}`);
2536
+ } else if (attivita) {
2537
+ args.push(...argomentiHookCodex(EVENTI_CODEX,
2538
+ (evento) => comandoHook(evento, attivita.script, attivita.dirSessione, attivita.generazione)));
2539
+ // Qui si dichiara che QUESTA cella ha un canale di attivita': e' il
2540
+ // `dir` che fa viaggiare verso il supervisore il dove e il
2541
+ // con-che-generazione dell'evento di uscita (runtime.js) e la
2542
+ // scrittura di `activity.gen` da parte di cell-exec, e senza di esso un
2543
+ // `Stop` dell'ultimo turno sopravviverebbe al riavvio interno del
2544
+ // client.
2545
+ activityDir = attivita.dirSessione;
2546
+ }
2547
+ }
2499
2548
  if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
2500
2549
  else if (spec.provider === 'openai-api') env.OPENAI_API_KEY = cred.value;
2501
2550
  else if (spec.provider === 'ollama-cloud') {
@@ -17,11 +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,
22
23
  knownMcpServerNames,
23
24
  } = require('./managed.js');
24
25
  const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
26
+ const { NC_SUPERVISOR_OPT } = require('../tmux/supervisor-pane.js');
25
27
  const {
26
28
  httpError, minimalEnv, tmuxExec,
27
29
  composeClientInvocation, alternateScreenArgs,
@@ -29,7 +31,6 @@ const {
29
31
  redactSecrets, sanitizeEarlyDiagnostic,
30
32
  } = require('./launch.js');
31
33
  const { waitDeliveryReport, actionRequiredFor, vlPaneReadiness } = require('./prompt-delivery.js');
32
- const { scriviGenerazione } = require('../files/activity.js');
33
34
 
34
35
  // Insieme MCP MATERIALIZZATO della cella — le chiavi del file che
35
36
  // writeCellMcpConfig (managed.js) scrive PRIMA dello spawn. Un server non
@@ -178,7 +179,12 @@ function createBuiltinRuntime(ctx) {
178
179
  // engineDef gia' usata sopra per la permission policy: un valore
179
180
  // presente qui e' gia' passato da validPanelUrl a monte (parseEngine/
180
181
  // parseCell), quindi si copia, non si ri-valida.
181
- 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 || '');
182
188
  return {
183
189
  // `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
184
190
  // leggibile e viaggia accanto, senza mai sostituirlo.
@@ -290,7 +296,32 @@ function createBuiltinRuntime(ctx) {
290
296
  if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
291
297
  const engine = findEngine(defs, cell.engine);
292
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
+ }
293
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;
294
325
  if (engine.managed) {
295
326
  // Generazione del lancio: identifica QUESTA partenza della cella. Uno
296
327
  // stato di attivita' che ne porta un'altra appartiene a un lancio
@@ -301,6 +332,11 @@ function createBuiltinRuntime(ctx) {
301
332
  ...cfg, home, extraModels: extraModelsFrom(defs),
302
333
  capabilityProfiles: defs.capabilityProfiles || null,
303
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.
304
340
  });
305
341
  // Il materializzatore MCP per cella e' FAIL-CLOSED: se il file per cella
306
342
  // non si scrive, la cella non parte — mai con la superficie intera dove
@@ -313,11 +349,10 @@ function createBuiltinRuntime(ctx) {
313
349
  throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
314
350
  }
315
351
  launchEngine = resolved.engine;
316
- // La generazione si scrive PRIMA che il client parta: gli hook possono
317
- // scattare appena il processo e' vivo, e uno stato che arrivasse prima
318
- // della generazione verrebbe scartato come se fosse di un lancio
319
- // precedente. Solo per le celle che hanno davvero gli hook.
320
- if (resolved.activityDir) scriviGenerazione(resolved.activityDir, generazione);
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 };
321
356
  // A vl cell whose runtime cannot hold the per-cell prompt
322
357
  // (VL_SYSTEM_APPEND_FILE, 0.3.1+) DEGRADES by declaring it — same
323
358
  // shape as readinessDegraded: the cell starts without its own identity
@@ -392,6 +427,13 @@ function createBuiltinRuntime(ctx) {
392
427
  ...(typeof launchEngine.identityChannel === 'boolean'
393
428
  ? { identityChannel: launchEngine.identityChannel } : {}),
394
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 } : {}),
395
437
  supervise: {
396
438
  enabled: !launchEngine.shellOneShot,
397
439
  initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
@@ -468,6 +510,26 @@ function createBuiltinRuntime(ctx) {
468
510
  throw httpError(dup ? 409 : 500, why, null,
469
511
  { phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
470
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
+ }
471
533
  // Best effort per la sola sessione appena creata: non tocchiamo mai
472
534
  // ~/.tmux.conf, opzioni globali o sessioni preesistenti. Un tmux che non
473
535
  // accetta una delle opzioni non deve impedire l'avvio della cella.
@@ -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());
package/lib/server.js CHANGED
@@ -8,7 +8,8 @@ const express = require('express');
8
8
  const { WebSocketServer } = require('ws');
9
9
  const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
10
10
  const { writeConfigAtomic } = require('./cli/init.js');
11
- const { listSessions, attachedClients, setSessionVisibility } = require('./tmux/list.js');
11
+ const { listSessions, listPanes, attachedClients, setSessionVisibility } = require('./tmux/list.js');
12
+ const { statiSupervisore: statiSupervisoreDi, senzaSupervisore } = require('./tmux/supervisor-pane.js');
12
13
  const { runAction, pasteToSession, submitToSession } = require('./tmux/actions.js');
13
14
  const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
14
15
  const { createPreviewSampler } = require('./tmux/preview.js');
@@ -20,7 +21,7 @@ const { requireToken, bearerFrom } = require('./auth/middleware.js');
20
21
  const { filesRoutes } = require('./files/routes.js');
21
22
  const { createOutboxWatcher } = require('./files/watcher.js');
22
23
  const { leggiTelemetria } = require('./files/telemetry.js');
23
- const { leggiAttivita } = require('./files/activity.js');
24
+ const { leggiAttivita, leggiGenerazione } = require('./files/activity.js');
24
25
  const VERSION = require('../package.json').version;
25
26
  const { transcribe } = require('./voice/transcribe.js');
26
27
  const { selectProvider } = require('./fleet/provider.js');
@@ -976,6 +977,10 @@ function createServer(opts = {}) {
976
977
  api.get('/sessions', async (_req, res) => {
977
978
  try {
978
979
  const sessions = await listSessions(cfg.tmuxBin);
980
+ // UNA chiamata per giro per sapere chi ha ancora il suo supervisore.
981
+ const statiSupervisore = sessions.length
982
+ ? statiSupervisoreDi(await listPanes(cfg.tmuxBin))
983
+ : new Map();
979
984
  const sum = watcher.getSummary();
980
985
  const enriched = await Promise.all(sessions.map(async (s) => {
981
986
  const sample = await previews.getState(s.name);
@@ -983,6 +988,7 @@ function createServer(opts = {}) {
983
988
  // the capture fallback. This prevents a quoted "• Working (...)" line
984
989
  // inside another agent's transcript from becoming a false positive.
985
990
  const piTitle = /^(?:π|pi)(?:\s+-|$)/iu.test(s.paneTitle);
991
+ const statoDelSupervisore = statiSupervisore.get(s.name) || 'assente';
986
992
  const working = s.working === true || (piTitle && sample?.working === true);
987
993
  return {
988
994
  ...s,
@@ -999,7 +1005,28 @@ function createServer(opts = {}) {
999
1005
  // nessun evento entro la finestra, oppure una cella che non ha questo
1000
1006
  // canale (codex-vl, pi, shell). La UI non deve dedurre «ferma» da un
1001
1007
  // silenzio: e' esattamente l'errore che il titolo del pane fa oggi.
1002
- attivita: leggiAttivita(cfg.filesRoot, s.name),
1008
+ //
1009
+ // E SI LEGGE SOLO SE IL SUPERVISORE DELLA CELLA ESISTE ANCORA. Con
1010
+ // `remain-on-exit` (runtime.js) la sessione sopravvive al suo
1011
+ // processo: un supervisore ucciso (SIGKILL, OOM) lascia la sessione
1012
+ // «attiva», e lo `Stop` con `exit:1` scritto prima resterebbe «ferma»
1013
+ // per sempre — mentre non c'e' nessun client a cui quello stato si
1014
+ // riferisca. La verita' e' il pane che esegue cell-exec, marcato al
1015
+ // lancio: `#{pane_dead}` della sessione riguarda il pane ATTIVO, e con
1016
+ // una seconda finestra viva selezionata direbbe «vivo» a supervisore
1017
+ // morto (misurato). La decisione completa sta in
1018
+ // `lib/tmux/supervisor-pane.js`: qui si passa lo stato del pane e — per
1019
+ // il solo caso «nessun marcatore» — il segno `exit:1` del file, che
1020
+ // distingue un lancio nuovo con marcatura fallita (non si legge: il
1021
+ // suo `Stop` non scadrebbe) da un lancio VECCHIO (si legge, e il
1022
+ // lettore fa scadere tutto a 5 minuti). E' quello che evita di
1023
+ // mostrare «non verificato» su ogni cella viva di ogni nodo appena
1024
+ // aggiornato.
1025
+ attivita: statoDelSupervisore === 'vivo'
1026
+ ? leggiAttivita(cfg.filesRoot, s.name)
1027
+ : (senzaSupervisore(statoDelSupervisore, leggiGenerazione(path.join(cfg.filesRoot, s.name)).uscitaGarantita)
1028
+ ? null
1029
+ : leggiAttivita(cfg.filesRoot, s.name)),
1003
1030
  };
1004
1031
  }));
1005
1032
  res.json({ sessions: enriched });