@mmmbuto/nexuscrew 0.9.1 → 0.9.3

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,7 +11,7 @@
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-Bu_-2-Uu.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-uRh_hSop.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-0vuhL1YP.css">
16
16
  </head>
17
17
  <body>
@@ -1 +1 @@
1
- {"version":"0.9.1"}
1
+ {"version":"0.9.2"}
package/lib/cli/init.js CHANGED
@@ -17,7 +17,7 @@ const {
17
17
  generateFleetService, installFleetService, migrationGate,
18
18
  selectProviderModeSync, fleetFileMode,
19
19
  } = require('./fleet-service.js');
20
- const { atomicWrite: writeFleet } = require('../fleet/definitions.js');
20
+ const { aggiornaDefinizioni } = require('../fleet/definitions.js');
21
21
  const { defaultDefinitions } = require('../fleet/managed.js');
22
22
  const { commandExists, resolveCommand } = require('./path.js');
23
23
 
@@ -83,8 +83,23 @@ function ensureFleetDefaults(opts = {}) {
83
83
  if (e.code !== 'ENOENT') throw e;
84
84
  }
85
85
 
86
- writeFleet(fleetDefsPath, defaultDefinitions());
87
- return { path: fleetDefsPath, created: true, enabled: true };
86
+ // Fra il controllo qui sopra e la scrittura c'e' una finestra: se un altro
87
+ // processo crea le definizioni nel frattempo, scrivere i default sopra le sue
88
+ // significa cancellare una configurazione intera — non perdere una modifica.
89
+ // La creazione avviene quindi sotto lock, con l'esistenza RIVERIFICATA
90
+ // dentro: `seMancante` viene chiamata solo se il file davvero non c'e'.
91
+ // `propaga` perche' `created` dica il vero: senza, una rinuncia del lock
92
+ // restituiva lo stato riletto — truthy — e l'init dichiarava `created: true`
93
+ // per un file che non aveva creato.
94
+ let creato = null;
95
+ try {
96
+ creato = aggiornaDefinizioni(fleetDefsPath, () => null, {
97
+ seMancante: () => defaultDefinitions(),
98
+ propaga: true,
99
+ log: opts.log,
100
+ });
101
+ } catch (_) { creato = null; }
102
+ return { path: fleetDefsPath, created: Boolean(creato), enabled: true };
88
103
  }
89
104
 
90
105
  // Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ // Telemetria per-cella: contesto LIBERO e tier 5h/7d USATI, letti dal file che
3
+ // la statusline di Claude Code scrive in <root>/<sessione>/telemetry.json
4
+ // (snippet documentato in docs/STATUSLINE_TELEMETRY.md, NON applicato: la
5
+ // statusline e' dell'operatore). Il verso dei due dati e' OPPOSTO e i nomi del file lo
6
+ // portano scritto dentro: `contextFreePct` e' quanto RESTA, `tier*UsedPct` e'
7
+ // quanto e' STATO CONSUMATO. Confondere i versi produce una riga che dice il
8
+ // contrario del vero e fa prendere la decisione opposta a quella giusta.
9
+ //
10
+ // Tre regole non opinabili, tutte implementate qui:
11
+ // 1. TIMESTAMP OBBLIGATORIO: oltre MASSIMA_ETA_MS il dato e' morto e viene
12
+ // restituito null — un numero stantio che sembra fresco e' peggio di un
13
+ // numero assente.
14
+ // 2. ASSENZA LEGITTIMA: le celle non-Claude (codex-vl, agy, grok, shell) non
15
+ // hanno quella statusline e non avranno mai questo file. File assente =
16
+ // null, e la riga della lista resta com'era: niente campo, niente
17
+ // trattino, niente «n/d».
18
+ // 3. LETTURA TOLLERANTE: file illeggibile, JSON rotto, campi mancanti o
19
+ // fuori contratto — si degrada a null senza mai far fallire la lista.
20
+ //
21
+ // CONTRATTO del file: valori INTERI 0..100 (gia' percentuali — la
22
+ // normalizzazione frazione→percentuale e' compito di chi scrive, vedi lo
23
+ // snippet). Il lettore accetta SOLO interi: una frazione scritta per errore
24
+ // (0.5 che voleva essere 50%) viene rifiutata, non arrotondata a 1% — un
25
+ // numero sbagliato mostrato con sicurezza e' il difetto che conta, meglio
26
+ // nessun numero.
27
+
28
+ const fs = require('node:fs');
29
+ const path = require('node:path');
30
+
31
+ // La statusline aggiorna a ogni evento del modello: in una cella viva il file
32
+ // e' sempre piu' fresco di cosi'. Cinque minuti coprono una pausa pranzo senza
33
+ // mostrare come attuale il dato di ieri.
34
+ const MASSIMA_ETA_MS = 5 * 60 * 1000;
35
+ // La soglia guarda in ENTRAMBI i versi. Un ts nel futuro farebbe `ora - ts`
36
+ // negativo: la differenza non supera MAI la massima eta' e il dato resterebbe
37
+ // «fresco» per sempre — un orologio avanti, o uno ts scritto male, e la riga
38
+ // mostra un numero morto che non scadra' mai. Due minuti di skew sono il
39
+ // margine che un orologio legittimamente sforato puo' avere; oltre, il ts e'
40
+ // rotto e il dato non esiste.
41
+ const FUTURO_TOLLERATO_MS = 2 * 60 * 1000;
42
+
43
+ const NOME_FILE = 'telemetry.json';
44
+
45
+ // Accetta SOLO interi 0..100 gia' numeri. Tutto il resto e' fuori contratto
46
+ // e non viene mostrato — in particolare null e i booleani: `Number(null)` e'
47
+ // 0 e `Number(true)` e' 1, e un campo assente letto come «0% usato» e'
48
+ // esattamente il numero sbagliato-mostrato-con-sicurezza che questo modulo
49
+ // esiste per evitare.
50
+ function percentualeIntera(valore) {
51
+ if (typeof valore !== 'number' || !Number.isInteger(valore)) return null;
52
+ if (valore < 0 || valore > 100) return null;
53
+ return valore;
54
+ }
55
+
56
+ /**
57
+ * Legge la telemetria di una sessione. Ritorna `{ ts, contextFreePct?,
58
+ * tier5hUsedPct?, tier7dUsedPct? }` con solo i campi validi, oppure null per
59
+ * assenza legittima, dato stantio o qualsiasi rotture. Non lancia MAI.
60
+ */
61
+ function leggiTelemetria(root, sessione, ora = Date.now()) {
62
+ try {
63
+ let raw;
64
+ try {
65
+ raw = fs.readFileSync(path.join(root, String(sessione), NOME_FILE), 'utf8');
66
+ } catch (_) {
67
+ return null; // assente (o non leggibile): cella non-Claude o primo avvio
68
+ }
69
+ const dato = JSON.parse(raw);
70
+ if (!dato || typeof dato !== 'object' || Array.isArray(dato)) return null;
71
+ const ts = Number(dato.ts);
72
+ // Senza timestamp non c'e' freschezza da verificare: il dato non esiste.
73
+ if (!Number.isFinite(ts)) return null;
74
+ if (ora - ts > MASSIMA_ETA_MS) return null; // stantio = assente
75
+ if (ts - ora > FUTURO_TOLLERATO_MS) return null; // ts rotto: «fresco per sempre» non e' fresco
76
+ const campi = {};
77
+ const libero = percentualeIntera(dato.contextFreePct);
78
+ const t5 = percentualeIntera(dato.tier5hUsedPct);
79
+ const t7 = percentualeIntera(dato.tier7dUsedPct);
80
+ if (libero !== null) campi.contextFreePct = libero;
81
+ if (t5 !== null) campi.tier5hUsedPct = t5;
82
+ if (t7 !== null) campi.tier7dUsedPct = t7;
83
+ return Object.keys(campi).length ? { ts, ...campi } : null;
84
+ } catch (_) {
85
+ return null; // JSON rotto o altro: la lista non fallisce per questo
86
+ }
87
+ }
88
+
89
+ module.exports = { leggiTelemetria, MASSIMA_ETA_MS, FUTURO_TOLLERATO_MS, NOME_FILE };
@@ -26,10 +26,12 @@
26
26
  // mutazione fleet e ogni up (§9d): passano solo status/schema/capabilities.
27
27
  // - promptMode 'send-keys' inietta via bracketed paste; se il command e' gia'
28
28
  // uscito (sessione morta) NON digita (§9e).
29
+ const fs = require('node:fs');
29
30
  const os = require('node:os');
30
31
  const path = require('node:path');
31
32
  const {
32
- loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
33
+ loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName, validateCommandTrust,
34
+ aggiornaDefinizioni,
33
35
  cellIdFromTmuxSession,
34
36
  resolveCwd, normalizeCwdRel, deriveCwdRel,
35
37
  } = require('./definitions.js');
@@ -86,13 +88,20 @@ function draftFrom(defs) {
86
88
  // ricevono l'engine standard Shell senza riscrivere celle o sostituire un id
87
89
  // scelto dall'utente. Se lo store e' pieno o la scrittura non e' possibile, il
88
90
  // bootstrap resta utilizzabile con le definizioni precedenti.
89
- function backfillShellEngine(defsPath, defs) {
90
- if (!defs || defs.engines.some((engine) => engine.managed?.client === 'shell')) return defs;
91
- if (defs.engines.some((engine) => engine.id === 'shell.local')) return defs;
92
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
93
- const draft = draftFrom(defs);
94
- draft.engines.push(defaultShellEngine());
95
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
91
+ function backfillShellEngine(defsPath, defs, log) {
92
+ if (!defs) return defs;
93
+ // Le condizioni si valutano su cio' che si legge DENTRO il lock, non
94
+ // sullo stato che avevamo in mano: e' la differenza fra decidere sul
95
+ // presente e decidere su una fotografia.
96
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
97
+ if (dentro.engines.some((engine) => engine.managed?.client === 'shell')) return null;
98
+ if (dentro.engines.some((engine) => engine.id === 'shell.local')) return null;
99
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
100
+ const draft = draftFrom(dentro);
101
+ draft.engines.push(defaultShellEngine());
102
+ return draft;
103
+ }, { log });
104
+ return esito || defs;
96
105
  }
97
106
 
98
107
  // Backfill platform-aware dell'engine Agy primario (design §4.2): installazioni
@@ -110,12 +119,17 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
110
119
  const termux = platform === 'android'
111
120
  || termuxRuntimePaths(cfg.env || process.env, { platform, home: cfg.home }) !== null;
112
121
  if (termux || (platform !== 'linux' && platform !== 'darwin')) return defs;
113
- if (defs.engines.some((engine) => engine.managed?.client === 'agy')) return defs;
114
- if (defs.engines.some((engine) => engine.id === 'agy.native')) return defs;
115
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
116
- const draft = draftFrom(defs);
117
- draft.engines.push(defaultAgyEngine());
118
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
122
+ // Il gate di piattaforma non dipende dallo store e resta fuori dal lock:
123
+ // non si tiene un lock per rispondere a una domanda sul sistema.
124
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
125
+ if (dentro.engines.some((engine) => engine.managed?.client === 'agy')) return null;
126
+ if (dentro.engines.some((engine) => engine.id === 'agy.native')) return null;
127
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
128
+ const draft = draftFrom(dentro);
129
+ draft.engines.push(defaultAgyEngine());
130
+ return draft;
131
+ }, { log: cfg.log });
132
+ return esito || defs;
119
133
  }
120
134
 
121
135
  // Engine desktop.local: NON managed (nessun client/provider AI, solo command+args
@@ -127,11 +141,73 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
127
141
  // meccanismo condiviso (validPanelUrl in definitions.js): l'engine non ha una
128
142
  // sua strada per imporre l'URL, dichiara solo il valore, e resta sovrascrivibile
129
143
  // per-cella come qualunque altro panelUrl (vedi parseCell).
144
+ // Risolve un eseguibile del PATH nel suo path assoluto REALE. Il realpath non
145
+ // e' pignoleria: `validateCommandTrust` usa `lstat` e rifiuta i symlink — e in
146
+ // molte installazioni il primo hit nel PATH e' proprio un symlink. Senza questo
147
+ // passaggio l'engine sarebbe rifiutato dalla stessa trust boundary del
148
+ // progetto, con un messaggio che parla di path assoluti mentre il problema e'
149
+ // un link.
150
+ function risolviEseguibile(nome) {
151
+ const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
152
+ for (const dir of dirs) {
153
+ let reale;
154
+ try { reale = fs.realpathSync(path.join(dir, nome)); } catch (_) { continue; }
155
+ // La decisione la prende la STESSA funzione che deciderà al salvataggio.
156
+ // Riscriverne una copia qui significava poter divergere, ed era divergente:
157
+ // un `docker` 0777 passava il controllo locale (file + eseguibile) e veniva
158
+ // poi rifiutato come world-writable — cioè il default proposto non superava
159
+ // la validazione che lo attendeva.
160
+ if (!validateCommandTrust(reale).ok) continue;
161
+ // In più, una condizione che la validazione NON esprime: un binario
162
+ // ineccepibile dentro una directory scrivibile da chiunque è sostituibile
163
+ // da chiunque. Il salvataggio lo accetterebbe; noi non lo PROPONIAMO —
164
+ // scegliere il default è nostro, e su una scelta nostra si può essere più
165
+ // prudenti del minimo richiesto.
166
+ if (dirScrivibileDaTutti(reale)) continue;
167
+ return reale;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ // Guarda la directory che CONTIENE l'eseguibile, non l'eseguibile: è lì che si
173
+ // decide chi può sostituirlo. Sticky bit escluso (come /tmp: lì il rename
174
+ // altrui è già impedito dal kernel).
175
+ function dirScrivibileDaTutti(file) {
176
+ // Tutta la CATENA, non il solo genitore: con `/a` scrivibile da chiunque,
177
+ // `/a/b/docker` resta sostituibile rinominando `b` — il binario e la sua
178
+ // directory immediata possono essere ineccepibili e il percorso no. Un audit
179
+ // ha riprodotto esattamente questo caso su un controllo fermo al genitore.
180
+ //
181
+ // Lo sticky bit interrompe la risalita per quel livello: li' il kernel
182
+ // impedisce gia' di rinominare o rimuovere roba altrui (e' il caso di /tmp).
183
+ let dir = path.dirname(file);
184
+ for (;;) {
185
+ let st;
186
+ try { st = fs.statSync(dir); } catch (_) { return true; } // non ispezionabile: scarta
187
+ if ((st.mode & 0o002) && !(st.mode & 0o1000)) return true;
188
+ const su = path.dirname(dir);
189
+ if (su === dir) return false; // radice raggiunta: catena pulita
190
+ dir = su;
191
+ }
192
+ }
193
+
194
+ // Il command DEVE essere un path assoluto: `validateCommandTrust` e' la trust
195
+ // boundary degli engine, e un nome relativo si risolverebbe via PATH — cioe'
196
+ // via qualcosa che l'ambiente puo' cambiare sotto di noi. Dichiarare 'docker'
197
+ // rendeva questo engine built-in NON avviabile: veniva offerto nell'elenco e
198
+ // rifiutato al salvataggio con «command deve essere un path assoluto», un
199
+ // errore che descrive la forma del valore invece di dire che il comando non
200
+ // era stato risolto.
201
+ //
202
+ // Se docker non e' installato si ricade sul path convenzionale: l'engine resta
203
+ // visibile e il rifiuto diventa «non accessibile (ENOENT)», che nomina la
204
+ // causa vera — docker manca — invece di sembrare un difetto di
205
+ // configurazione.
130
206
  function defaultDesktopEngine() {
131
207
  return {
132
208
  id: 'desktop.local',
133
209
  label: 'Desktop',
134
- command: 'docker',
210
+ command: risolviEseguibile('docker') || '/usr/bin/docker',
135
211
  args: ['exec', '-it', '-u', 'abc', 'ai-desktop', 'bash'],
136
212
  promptMode: 'send-keys',
137
213
  panelUrl: 'https://127.0.0.1:6901',
@@ -160,12 +236,82 @@ function defaultDesktopEngine() {
160
236
  // bootstrap: una dipendenza nuova sul percorso di avvio per una comodita'.
161
237
  // Resta MANUALE: chi ha il container se lo aggiunge nella propria procedura
162
238
  // — e' il posto dove qualcuno sa gia' che il container esiste.
163
- function backfillDesktopEngine(defsPath, defs) {
164
- if (!defs || defs.engines.some((engine) => engine.id === 'desktop.local')) return defs;
165
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
166
- const draft = draftFrom(defs);
167
- draft.engines.push(defaultDesktopEngine());
168
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
239
+ function backfillDesktopEngine(defsPath, defs, log) {
240
+ if (!defs) return defs;
241
+ if (defs.engines.some((engine) => engine.id === 'desktop.local')) {
242
+ return riparaDesktopEngine(defsPath, defs, log);
243
+ }
244
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
245
+ if (dentro.engines.some((engine) => engine.id === 'desktop.local')) return null;
246
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
247
+ const draft = draftFrom(dentro);
248
+ draft.engines.push(defaultDesktopEngine());
249
+ return draft;
250
+ }, { log });
251
+ return esito || defs;
252
+ }
253
+
254
+ // A DIFFERENZA del backfill qui sopra, questa e' agganciata al bootstrap, e
255
+ // non contraddice la decisione del 2026-08-14: non AGGIUNGE l'engine a chi non
256
+ // ce l'ha e non presuppone alcun container: tocca soltanto un `desktop.local`
257
+ // gia' presente, il cui comando e' rotto per costruzione. Senza quell'aggancio
258
+ // la funzione non aveva chiamanti — scritta, testata e mai eseguita, con un
259
+ // test verde perche' la invocava direttamente.
260
+ //
261
+ // Le installazioni che hanno gia' ricevuto il backfill portano in
262
+ // configurazione un `command` RELATIVO, che la trust boundary rifiuta: per
263
+ // loro l'aggiornamento del default non cambia nulla, perche' il backfill salta
264
+ // cio' che esiste gia'. Senza questa riparazione il difetto resterebbe
265
+ // esattamente dove e' stato visto — su una macchina gia' configurata.
266
+ //
267
+ // Prudente per costruzione: interviene SOLO se il comando non e' assoluto e si
268
+ // chiama ancora `docker`. Un path assoluto — o un comando che l'utente ha
269
+ // cambiato in altro — non viene toccato: quella e' una scelta, non un residuo.
270
+ function riparaDesktopEngine(defsPath, defs, log = () => {}) {
271
+ if (!defs || !Array.isArray(defs.engines)) return defs;
272
+ if (!eDaRiparare(defs)) return defs; // scarto rapido, senza lock
273
+ const risolto = risolviEseguibile('docker');
274
+ if (!risolto) return defs; // niente docker fidato: invariato
275
+
276
+ // La condizione si rivaluta DENTRO il lock, sullo stato appena riletto: fra
277
+ // il nostro scarto rapido e la scrittura qualcuno puo' aver gia' corretto
278
+ // quella voce, o averla cambiata in altro. Rinunciare costa un altro avvio;
279
+ // sovrascrivere costa un dato.
280
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
281
+ if (!eDaRiparare(dentro)) return null;
282
+ const draft = draftFrom(dentro);
283
+ const voce = draft.engines.find((engine) => engine.id === 'desktop.local');
284
+ if (!voce) return null;
285
+ voce.command = risolto;
286
+ return draft;
287
+ }, {
288
+ log: (m) => log(m),
289
+ });
290
+ if (!esito) {
291
+ log('desktop.local: riparazione del comando non persistita (definizioni non leggibili)');
292
+ return defs;
293
+ }
294
+ if (esito.engines.find((e) => e.id === 'desktop.local')?.command === 'docker') {
295
+ // Il lock c'era ma la scrittura non ha attecchito: dirlo, invece di
296
+ // lasciare l'avvio convinto di aver fatto il suo lavoro.
297
+ log('desktop.local: riparazione del comando non persistita');
298
+ }
299
+ return esito;
300
+ }
301
+
302
+ // La condizione di riparabilità, in un posto solo perché va valutata due volte:
303
+ // sullo stato in mano e su quello riletto un istante prima di scrivere.
304
+ //
305
+ // `docker` NUDO, non un basename qualsiasi: `vendor/docker` è un percorso
306
+ // relativo che qualcuno ha scritto apposta per il proprio binario, e
307
+ // sostituirlo con il Docker di sistema è distruggere una scelta, non riparare
308
+ // un residuo. Il residuo che stiamo correggendo è esattamente la stringa che il
309
+ // nostro engine dichiarava.
310
+ function eDaRiparare(defs) {
311
+ if (!defs || !Array.isArray(defs.engines)) return false;
312
+ const voce = defs.engines.find((engine) => engine.id === 'desktop.local');
313
+ if (!voce) return false;
314
+ return voce.command === 'docker';
169
315
  }
170
316
 
171
317
  // Backfill dell'engine Kimi Code CLI nativo: installazioni esistenti ricevono
@@ -174,13 +320,20 @@ function backfillDesktopEngine(defsPath, defs) {
174
320
  // shebang. Idempotente (gia' presente -> skip), NON sovrascrive un id
175
321
  // 'kimi.native' gia' scelto dall'utente per altro (collisione -> skip, store
176
322
  // invariato), rispetta il cap MAX_ENGINES. Non tocca CELLE.
177
- function backfillKimiEngine(defsPath, defs) {
178
- if (!defs || defs.engines.some((engine) => engine.managed?.client === 'kimi')) return defs;
179
- if (defs.engines.some((engine) => engine.id === 'kimi.native')) return defs;
180
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
181
- const draft = draftFrom(defs);
182
- draft.engines.push(defaultKimiEngine());
183
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
323
+ function backfillKimiEngine(defsPath, defs, log) {
324
+ if (!defs) return defs;
325
+ // Le condizioni si valutano su cio' che si legge DENTRO il lock, non
326
+ // sullo stato che avevamo in mano: e' la differenza fra decidere sul
327
+ // presente e decidere su una fotografia.
328
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
329
+ if (dentro.engines.some((engine) => engine.managed?.client === 'kimi')) return null;
330
+ if (dentro.engines.some((engine) => engine.id === 'kimi.native')) return null;
331
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
332
+ const draft = draftFrom(dentro);
333
+ draft.engines.push(defaultKimiEngine());
334
+ return draft;
335
+ }, { log });
336
+ return esito || defs;
184
337
  }
185
338
 
186
339
  // Backfill platform-aware dell'engine Grok Build (grok.native): come Agy,
@@ -196,12 +349,17 @@ function backfillGrokEngine(defsPath, defs, cfg = {}) {
196
349
  const termux = platform === 'android'
197
350
  || termuxRuntimePaths(cfg.env || process.env, { platform, home: cfg.home }) !== null;
198
351
  if (termux || (platform !== 'linux' && platform !== 'darwin')) return defs;
199
- if (defs.engines.some((engine) => engine.managed?.client === 'grok')) return defs;
200
- if (defs.engines.some((engine) => engine.id === 'grok.native')) return defs;
201
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
202
- const draft = draftFrom(defs);
203
- draft.engines.push(defaultGrokEngine());
204
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
352
+ // Il gate di piattaforma non dipende dallo store e resta fuori dal lock:
353
+ // non si tiene un lock per rispondere a una domanda sul sistema.
354
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
355
+ if (dentro.engines.some((engine) => engine.managed?.client === 'grok')) return null;
356
+ if (dentro.engines.some((engine) => engine.id === 'grok.native')) return null;
357
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
358
+ const draft = draftFrom(dentro);
359
+ draft.engines.push(defaultGrokEngine());
360
+ return draft;
361
+ }, { log: cfg.log });
362
+ return esito || defs;
205
363
  }
206
364
 
207
365
  // Backfill dell'engine VL/Vivling (vl.native): come Kimi, NESSUN platform gate
@@ -209,13 +367,20 @@ function backfillGrokEngine(defsPath, defs, cfg = {}) {
209
367
  // -> skip), NON sovrascrive un id 'vl.native' gia' scelto dall'utente per altro
210
368
  // (collisione -> skip, store invariato), rispetta il cap MAX_ENGINES. Non tocca
211
369
  // CELLE.
212
- function backfillVlEngine(defsPath, defs) {
213
- if (!defs || defs.engines.some((engine) => engine.managed?.client === 'vl')) return defs;
214
- if (defs.engines.some((engine) => engine.id === 'vl.native')) return defs;
215
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
216
- const draft = draftFrom(defs);
217
- draft.engines.push(defaultVlEngine());
218
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
370
+ function backfillVlEngine(defsPath, defs, log) {
371
+ if (!defs) return defs;
372
+ // Le condizioni si valutano su cio' che si legge DENTRO il lock, non
373
+ // sullo stato che avevamo in mano: e' la differenza fra decidere sul
374
+ // presente e decidere su una fotografia.
375
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
376
+ if (dentro.engines.some((engine) => engine.managed?.client === 'vl')) return null;
377
+ if (dentro.engines.some((engine) => engine.id === 'vl.native')) return null;
378
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
379
+ const draft = draftFrom(dentro);
380
+ draft.engines.push(defaultVlEngine());
381
+ return draft;
382
+ }, { log });
383
+ return esito || defs;
219
384
  }
220
385
 
221
386
  // Applica engine + modello + policy come un'unica transizione. Ogni engine ricorda
@@ -413,6 +578,16 @@ async function createBuiltinFleet(cfg = {}) {
413
578
  : off;
414
579
 
415
580
  if (!readonly()) {
581
+ // Audit 093, rilievo 3: lo snapshot della BASE si prende QUI, alla lettura
582
+ // che ha prodotto `boot` — non con una rilettura dopo la migrazione. Il
583
+ // vecchio `primaDelloScrivere` era una rilettura: una scrittura arrivata
584
+ // DURANTE migrateLegacyTmuxSessions (una catena di chiamate tmux: finestra
585
+ // larga) era già dentro la rilettura, il confronto passava e `boot`,
586
+ // costruito sullo stato pre-migrazione, cancellava il lavoro altrui. Con
587
+ // lo snapshot alla fonte, il confronto dentro il lock copre la finestra
588
+ // INTERA da questa lettura alla presa. `boot` e il futuro `dentro` sono
589
+ // entrambi normalizzati da loadDefinitions: il confronto resta coerente.
590
+ const baseAllaLettura = JSON.stringify(boot);
416
591
  // La migrazione precede QUALUNQUE backfill/scrittura: loadDefinitions normalizza
417
592
  // i nomi legacy solo in memoria. Se il rename e' ambiguo o fallisce, fleet.json
418
593
  // resta byte-invariato e la Fleet non puo creare una seconda sessione safe.
@@ -427,17 +602,36 @@ async function createBuiltinFleet(cfg = {}) {
427
602
  return blocked(`${detail} [${code}]`, code);
428
603
  }
429
604
  if (migration.needsPersistence) {
430
- try { boot = atomicWrite(defsPath, boot); }
431
- catch (_) {
605
+ // `boot` e' gia' stato mutato in memoria dalla migrazione: qui non si puo'
606
+ // ricostruire il draft dallo stato riletto, si puo' solo verificare che
607
+ // nessun altro abbia scritto nel frattempo — per TUTTA la finestra, da
608
+ // `baseAllaLettura` in poi (vedi il commento alla snapshot). Se qualcuno
609
+ // l'ha fatto si RINUNCIA: la migrazione e' idempotente e si riapplica al
610
+ // prossimo avvio, mentre sovrascrivere cancellerebbe il lavoro altrui.
611
+ let persistito = null;
612
+ try {
613
+ // `propaga` anche qui: senza, una rinuncia del lock tornava come stato
614
+ // valido e l'avvio proseguiva dichiarando implicitamente una migrazione
615
+ // che non era stata scritta.
616
+ persistito = aggiornaDefinizioni(defsPath, (dentro) => (
617
+ JSON.stringify(dentro) === baseAllaLettura ? boot : null
618
+ ), { propaga: true, log: cfg.log });
619
+ } catch (_) { persistito = null; }
620
+ if (!persistito) {
432
621
  return blocked('migrazione tmux completata ma fleet.json non e persistibile [TMUX_MIGRATION_PERSIST_FAILED]',
433
622
  'TMUX_MIGRATION_PERSIST_FAILED');
434
623
  }
624
+ boot = persistito;
435
625
  }
436
- boot = backfillShellEngine(defsPath, boot);
626
+ boot = backfillShellEngine(defsPath, boot, cfg.log);
437
627
  boot = backfillAgyEngine(defsPath, boot, cfg);
438
- boot = backfillKimiEngine(defsPath, boot);
628
+ boot = backfillKimiEngine(defsPath, boot, cfg.log);
439
629
  boot = backfillGrokEngine(defsPath, boot, cfg);
440
- boot = backfillVlEngine(defsPath, boot);
630
+ boot = backfillVlEngine(defsPath, boot, cfg.log);
631
+ // Ripara (non aggiunge) un desktop.local gia' presente col comando relativo.
632
+ // Il logger va PASSATO: senza, il messaggio di fallimento si ferma a un
633
+ // callback che nessuno fornisce — cioe' il silenzio che si voleva togliere.
634
+ boot = riparaDesktopEngine(defsPath, boot, cfg.log);
441
635
  }
442
636
 
443
637
  // Adopt or create the shared server before exposing a mutable Fleet. Reapply
@@ -500,14 +694,28 @@ async function createBuiltinFleet(cfg = {}) {
500
694
  // Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
501
695
  // invalido: backup predecessore + throw -> httpError(400) (mai garbage).
502
696
  async function mutate(defs, mutator) {
503
- const draft = draftFrom(defs);
504
- mutator(draft);
697
+ // Questo e' l'ALTRO scrittore: le mutazioni che arrivano dall'interfaccia.
698
+ // Un lock che valesse solo per l'avvio proteggerebbe il bootstrap da se'
699
+ // stesso e non da qui, cioe' da chi scrive davvero mentre il sistema gira.
700
+ //
701
+ // Il mutator lavora sullo stato riletto DENTRO il lock, non su quello che
702
+ // il chiamante aveva in mano: e' anche piu' corretto, perche' applica la
703
+ // modifica al presente invece che a una fotografia.
505
704
  let parsed;
506
705
  try {
507
- parsed = atomicWrite(defsPath, draft);
706
+ parsed = aggiornaDefinizioni(defsPath, (dentro) => {
707
+ const draft = draftFrom(dentro);
708
+ mutator(draft);
709
+ return draft;
710
+ }, { propaga: true });
508
711
  } catch (e) {
712
+ // Due fallimenti diversi, due risposte diverse: un input invalido e' 400
713
+ // e non cambiera' riprovando; un lock occupato e' 409 e riprovando puo'
714
+ // riuscire. Confonderli manda chi legge a correggere il dato sbagliato.
715
+ if (e && e.code === 'FLEET_LOCK_BUSY') throw httpError(409, 'definizioni fleet occupate: riprova');
509
716
  throw httpError(400, `definizioni non valide: ${e.message}`);
510
717
  }
718
+ if (!parsed) throw httpError(409, 'definizioni fleet non leggibili: riprova');
511
719
  commitDefs(parsed);
512
720
  return parsed;
513
721
  }
@@ -1201,6 +1409,7 @@ module.exports = {
1201
1409
  // Esportate ma NON chiamate qui sopra (§bootstrap, righe ~354-358): scelta
1202
1410
  // deliberata, non una dimenticanza — vedi il commento su backfillDesktopEngine.
1203
1411
  backfillDesktopEngine,
1412
+ riparaDesktopEngine,
1204
1413
  defaultDesktopEngine,
1205
1414
  resolveCellCwd,
1206
1415
  composeLaunchArgv,