@mmmbuto/nexuscrew 0.9.32 → 0.9.34

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-CH2iAu6y.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-5eCvm3HF.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-DqCfZFGi.css">
16
16
  </head>
17
17
  <body>
@@ -1 +1 @@
1
- {"version":"0.9.32"}
1
+ {"version":"0.9.34"}
@@ -34,11 +34,12 @@ const {
34
34
  aggiornaDefinizioni,
35
35
  cellIdFromTmuxSession,
36
36
  resolveCwd, normalizeCwdRel, deriveCwdRel,
37
+ effectiveCapabilities,
37
38
  } = require('./definitions.js');
38
39
  const {
39
40
  publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine,
40
41
  CATALOG, credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames, profileFor,
41
- normalizeManagedSpec,
42
+ normalizeManagedSpec, codexSurfaceNames,
42
43
  } = require('./managed.js');
43
44
  const { probeModel, probeCustomEndpoint } = require('./model-probe.js');
44
45
  const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
@@ -935,6 +936,51 @@ async function createBuiltinFleet(cfg = {}) {
935
936
  return { ok: true, id: wantedId, engine: wantedEngine };
936
937
  }
937
938
 
939
+ // I campi non-identita' che `parseModel` ammette per una dichiarazione:
940
+ // id ed engine SONO l'identita' (il chiaro per cui esiste edit-model e' non
941
+ // doverla ritoccare), tutto il resto e' patchabile con la stessa validazione.
942
+ const EDIT_MODEL_FIELDS = new Set(['label', 'contextWindow', 'maxTokens', 'reasoning']);
943
+
944
+ async function editModel(id, engine, patch) {
945
+ if (readonly()) throw httpError(403, 'READONLY: edit-model bloccato');
946
+ const wantedId = String(id || '').trim();
947
+ const wantedEngine = String(engine || '').trim();
948
+ if (!wantedId || !wantedEngine) throw httpError(400, 'id e engine sono obbligatori');
949
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw httpError(400, 'patch mancante');
950
+ const keys = Object.keys(patch);
951
+ if (!keys.length) throw httpError(400, 'patch vuota: niente da modificare');
952
+ for (const key of keys) {
953
+ if (!EDIT_MODEL_FIELDS.has(key)) throw httpError(400, `campo non ammesso per edit-model: ${key}`);
954
+ }
955
+ if (patch.label !== undefined) {
956
+ if (typeof patch.label !== 'string' || !patch.label.trim() || patch.label.trim().length > 64) {
957
+ throw httpError(400, 'label non valida: stringa non vuota, massimo 64 caratteri');
958
+ }
959
+ }
960
+ for (const key of ['contextWindow', 'maxTokens']) {
961
+ if (patch[key] !== undefined && (!Number.isSafeInteger(patch[key]) || patch[key] < 1)) {
962
+ throw httpError(400, `${key} non valido: intero >= 1`);
963
+ }
964
+ }
965
+ if (patch.reasoning !== undefined && typeof patch.reasoning !== 'boolean') {
966
+ throw httpError(400, 'reasoning non valido: booleano');
967
+ }
968
+ const defs = reloadDefs();
969
+ if (!(defs.models || []).some((m) => m.id === wantedId && m.engine === wantedEngine)) {
970
+ throw httpError(404, `modello non dichiarato: ${wantedId} su ${wantedEngine}`);
971
+ }
972
+ // La scrittura passa da `mutate`, che rilegge e RIVALIDA l'intero file: la
973
+ // validazione completa resta quella del parser (contextWindow >= 1024 fino
974
+ // al cap, label entro il limite) e un valore fuori schema non entra. Gli
975
+ // engine che risolvono questo modello NON vengono toccati: la dichiarazione
976
+ // e' la stessa riga del file, l'engine non ha nulla da ricaricare.
977
+ await mutate(defs, (d) => {
978
+ const target = d.models.find((m) => m.id === wantedId && m.engine === wantedEngine);
979
+ Object.assign(target, patch);
980
+ });
981
+ return { ok: true, id: wantedId, engine: wantedEngine, edited: keys };
982
+ }
983
+
938
984
  async function editEngine(id, patch, envChanges) {
939
985
  if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
940
986
  if (!id) throw httpError(400, 'id engine mancante');
@@ -1324,6 +1370,43 @@ async function createBuiltinFleet(cfg = {}) {
1324
1370
  ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
1325
1371
  ...(c.commands ? { commands: { ...c.commands } } : {}),
1326
1372
  };
1373
+ // Superficie dichiarata (solo vista): una cella LEGACY (niente
1374
+ // capabilities, niente profilo) e' la cella di sempre — esce con
1375
+ // `legacy: true` e nessun campo capabilities; una cella dichiarata
1376
+ // esce con la fusione profilo+override (sostitutivo per chiave) e con
1377
+ // le chiavi che il materializzatore NON copre per il suo engine
1378
+ // segnate come `unsupported` (motivo leggibile, MAI un errore). Il
1379
+ // materializzatore per engine arriva per fasi: qui la dichiarazione
1380
+ // onesta di cosa viene davvero materializzato.
1381
+ const eff = effectiveCapabilities(c, defs);
1382
+ out.legacy = eff.legacy;
1383
+ if (!eff.legacy) {
1384
+ const client = (defs.engines.find((e) => e.id === c.engine) || {}).managed?.client || null;
1385
+ const materializzaMcp = client === 'claude' || client === 'codex' || client === 'codex-vl';
1386
+ const materializzaOndemand = client === 'codex' || client === 'codex-vl';
1387
+ const nonMaterializzate = {
1388
+ mcp: `nessun materializzatore MCP per questo engine in questa fase (client: ${client || 'sconosciuto'})`,
1389
+ skills: 'le skill non sono ancora materializzate per cella (fase successiva)',
1390
+ ondemand: 'la materializzazione on-demand arriva in una fase successiva',
1391
+ };
1392
+ const unsupported = Object.keys(eff.declared)
1393
+ .filter((key) => !(key === 'mcp' && materializzaMcp) && !(key === 'ondemand' && materializzaOndemand))
1394
+ .map((key) => ({ key, reason: nonMaterializzate[key] }));
1395
+ // Per codex/codex-vl un nome ondemand che NON e' nella config utente
1396
+ // del nodo viene ignorato dal profilo (TOML non scrive tabelle per
1397
+ // server sconosciuti): la vista lo dice al punto, invece di farlo
1398
+ // scoprire all'operatore quando la capability non risponde.
1399
+ if ((client === 'codex' || client === 'codex-vl') && eff.declared.ondemand?.length) {
1400
+ try {
1401
+ const toml = fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8');
1402
+ const noti = new Set(codexSurfaceNames(toml).servers);
1403
+ for (const nome of eff.declared.ondemand.filter((n) => !noti.has(n))) {
1404
+ unsupported.push({ key: 'ondemand', name: nome, reason: `il server on-demand "${nome}" non e' dichiarato nella config utente del nodo: ignorato dal profilo` });
1405
+ }
1406
+ } catch (_) { /* config utente non leggibile: nessuna nota, non e' un errore di vista */ }
1407
+ }
1408
+ out.capabilities = { declared: eff.declared, ...(unsupported.length ? { unsupported } : {}) };
1409
+ }
1327
1410
  // cwdRel portatile derivato (solo vista): per celle valide (cwd reale
1328
1411
  // sotto home) espone il rel canonico; per celle persistite ma non
1329
1412
  // valide/fuori home espone needsRepair:true. NESSUNA mutazione di
@@ -1485,12 +1568,33 @@ async function createBuiltinFleet(cfg = {}) {
1485
1568
  // Nome leggibile, distinto dall'id: quest'ultimo resta la chiave di
1486
1569
  // indirizzamento e l'unica origine della sessione tmux.
1487
1570
  label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN },
1571
+ // Superficie operativa dichiarata della cella. Assenti entrambi = cella
1572
+ // legacy (comportamento di sempre). Il profilo alimenta le chiavi che
1573
+ // la cella non copre; l'override della cella sostituisce PER CHIAVE.
1574
+ capabilities: {
1575
+ type: 'object', required: false,
1576
+ mcp: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },
1577
+ skills: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
1578
+ ondemand: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
1579
+ },
1580
+ capabilityProfile: { type: 'string', required: false, ref: 'capabilityProfiles.id', max: 32 },
1581
+ },
1582
+ // Profili di ruolo condivisi del documento (top-level, accanto a
1583
+ // engines/cells): stessa forma del set di cella, referenziabili per nome.
1584
+ capabilityProfiles: {
1585
+ type: 'object', required: false, maxKeys: 32,
1586
+ keyPattern: '^[A-Za-z0-9._-]{1,32}$',
1587
+ valueShape: {
1588
+ mcp: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },
1589
+ skills: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
1590
+ ondemand: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
1591
+ },
1488
1592
  },
1489
1593
  };
1490
1594
  }
1491
1595
 
1492
1596
  function capabilities() {
1493
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test'];
1597
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test', 'edit-model'];
1494
1598
  }
1495
1599
 
1496
1600
  // Un engine del documento con un `baseUrl` dichiarato a mano: indirizzo e
@@ -1566,7 +1670,7 @@ async function createBuiltinFleet(cfg = {}) {
1566
1670
  defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
1567
1671
  schema, definitions, capabilities,
1568
1672
  credentialStatus, setLocalCredential, removeLocalCredential, testModel,
1569
- defineModel, removeModel, close,
1673
+ defineModel, editModel, removeModel, close,
1570
1674
  // Fetta 2b: il lease Live esposto alle route /api/lease, che il bridge
1571
1675
  // MCP delle celle consuma per register/refresh/recovery. Assente se il
1572
1676
  // lease e' disattivato: le route rispondono 501, non 500.
@@ -37,6 +37,9 @@ const CODES = [
37
37
  'CWD_INVALID',
38
38
  'ENGINE_UNCONFIGURED',
39
39
  'SHELL_NOT_AVAILABLE',
40
+ 'MCP_CELL_FILE_UNWRITABLE',
41
+ 'CAPABILITY_PROFILE_UNWRITABLE',
42
+ 'CAPABILITY_UNKNOWN_NAME',
40
43
  // launch-broker
41
44
  'LAUNCH_BROKER_UNSAFE',
42
45
  'LAUNCH_BROKER_PAYLOAD',
@@ -632,10 +632,10 @@ async function main(argv = process.argv.slice(2), seams = {}) {
632
632
  ? ['inherit', 'inherit', 'inherit', 'pipe', 'pipe']
633
633
  : ['inherit', 'inherit', 'inherit'],
634
634
  });
635
- // R1a (fix5): exit/error del figlio contano SUBITO, prima di qualunque
636
- // await — se il figlio esce durante l'handshake di generazione, l'evento
637
- // non deve andare perso (prima i listener arrivavano solo con waitChild,
638
- // e il supervisore restava appeso).
635
+ // exit/error del figlio contano SUBITO, prima di qualunque await — se
636
+ // il figlio esce durante l'handshake di generazione, l'evento non deve
637
+ // andare perso (prima i listener arrivavano solo con waitChild, e il
638
+ // supervisore restava appeso).
639
639
  let childSettled = false;
640
640
  const childExit = new Promise((resolve) => {
641
641
  current.once('exit', (code, signal) => {
@@ -649,16 +649,16 @@ async function main(argv = process.argv.slice(2), seams = {}) {
649
649
  });
650
650
  // Transizione di generazione comunicata al lease PRIMA di aprire il
651
651
  // canale identita' della generazione nuova. L'exit del figlio ha la
652
- // precedenza sull'annuncio; FAIL-CLOSED (R1b): solo un annuncio
653
- // RISOLTO con ok === true E generazione corrispondente apre il canale —
654
- // ok:false (lease-down, identity-unverified), rifiuto, timeout ed exit
655
- // restano tutti negativi, con sola diagnostica: il figlio parte
656
- // comunque, senza identita'.
652
+ // precedenza sull'annuncio; fail-closed: solo un annuncio RISOLTO con
653
+ // ok === true E generazione corrispondente apre il canale — ok:false
654
+ // (lease-down, identity-unverified), rifiuto, timeout ed exit restano
655
+ // tutti negativi, con sola diagnostica: il figlio parte comunque,
656
+ // senza identita'.
657
657
  if (leaseCtl && generation > 0) {
658
658
  const raced = await Promise.race([
659
- // fix 6 (R1b-bis): il valore RISOLTO va ispezionato.
660
- // Prima: .then(() => 'ok', ...) scartava il valore e trattava un
661
- // annuncio risolto {ok:false} come successo, aprendo il canale.
659
+ // Il valore RISOLTO va ispezionato: .then(() => 'ok', ...) scartava
660
+ // il valore e trattava un annuncio risolto {ok:false} come successo,
661
+ // aprendo il canale.
662
662
  leaseCtl.announceGeneration(generation).then(
663
663
  (announced) => (announced && announced.ok === true && announced.generation === generation
664
664
  ? 'ok'
@@ -426,10 +426,10 @@ function createLeaseManager(cfg = {}, seams = {}) {
426
426
  writeSafe(socket, { type: 'generationDeny', generation: msg.generation });
427
427
  return;
428
428
  }
429
- // R1b: l'avanzamento PRIMA dell'ACK e' voluto (at-least-once): se l'ACK
430
- // si perde, il client ritenta con la STESSA generazione e qui e'
431
- // idempotente (== corrente -> ACK). L'ordine inverso lascerebbe il server
432
- // stantio con un client che crede di aver transizionato.
429
+ // L'avanzamento PRIMA dell'ACK e' voluto (at-least-once): se l'ACK si
430
+ // perde, il client ritenta con la STESSA generazione e qui e' idempotente
431
+ // (== corrente -> ACK). L'ordine inverso lascerebbe il server stantio con
432
+ // un client che crede di aver transizionato.
433
433
  entry.lease.generation = msg.generation;
434
434
  writeSafe(socket, { type: 'generationAck', generation: msg.generation });
435
435
  }
@@ -39,6 +39,18 @@ const MCP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
39
39
  const MAX_CELL_COMMAND_LEN = 4096;
40
40
  const MAX_TMUXSESSION_LEN = 64;
41
41
 
42
+ // Capacita' dichiarate di una cella, per NOME (come `mcp`, che ne e' il caso
43
+ // particolare e l'alias legacy). Tre chiavi: `mcp` (server MCP), `skills`,
44
+ // `ondemand`. Profili di ruolo condivisi in `capabilityProfiles` (top-level del
45
+ // documento); la cella riferisce un profilo e puo' sostituire SINGOLE CHIAVI
46
+ // con il proprio set: l'override e' sostitutivo, non additivo, perche' unire
47
+ // non saprebbe togliere.
48
+ const MAX_CAPABILITIES = 64; // nomi per singola chiave
49
+ const MAX_CAPABILITY_PROFILES = 32; // profili dichiarabili nel documento
50
+ const CAPABILITY_KEYS = ['mcp', 'skills', 'ondemand'];
51
+ const CAPABILITY_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/; // skill/ondemand: slug
52
+ const CAPABILITY_KEY_RES = { mcp: MCP_NAME_RE, skills: CAPABILITY_NAME_RE, ondemand: CAPABILITY_NAME_RE };
53
+
42
54
  const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
43
55
  const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/; // cell id: ammette maiuscole (il punto e' un id umano valido)
44
56
  const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // identificatore env POSIX-like
@@ -279,6 +291,30 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
279
291
  engines.push(eng);
280
292
  }
281
293
 
294
+ // I profili di capability si leggono PRIMA delle celle: una cella riferisce
295
+ // un profilo per NOME, e leggerli dopo la farebbe rifiutare per un dato che
296
+ // in realta' c'e'. `capabilityProfiles` e' opzionale: senza, il documento
297
+ // resta com'era (chiave assente, non vuota).
298
+ let capabilityProfiles;
299
+ if (d.capabilityProfiles !== undefined) {
300
+ if (!d.capabilityProfiles || typeof d.capabilityProfiles !== 'object' || Array.isArray(d.capabilityProfiles)) return null;
301
+ const voci = Object.entries(d.capabilityProfiles);
302
+ if (voci.length > MAX_CAPABILITY_PROFILES) {
303
+ if (typeof onIssue === 'function') onIssue({ field: 'capabilityProfiles', value: voci.length, rule: `troppi profili di capability dichiarati, cap ${MAX_CAPABILITY_PROFILES}` });
304
+ return reject(`fleet.json rifiutato: ${voci.length} profili di capability dichiarati, cap ${MAX_CAPABILITY_PROFILES}; riduci i profili a ${MAX_CAPABILITY_PROFILES} o meno`);
305
+ }
306
+ capabilityProfiles = {};
307
+ for (const [nome, profilo] of voci) {
308
+ // Il nome e' un identificatore (finira' in riferimento per cella e, poi,
309
+ // in un nome di file generato): stessa forma chiusa dell'id cella.
310
+ if (typeof nome !== 'string' || !CELL_ID_RE.test(nome) || capabilityProfiles[nome] !== undefined) return null;
311
+ const set = parseCapabilitySet(profilo, onIssue);
312
+ if (!set) return null;
313
+ capabilityProfiles[nome] = set;
314
+ }
315
+ }
316
+ const capabilityProfileNames = capabilityProfiles ? new Set(Object.keys(capabilityProfiles)) : null;
317
+
282
318
  const tmuxSeen = new Set();
283
319
  const legacyTmuxSeen = new Set();
284
320
  const legacyTmuxSessions = new Map();
@@ -286,7 +322,7 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
286
322
  const cells = [];
287
323
  for (const c of d.cells) {
288
324
  const cell = parseCell(c, engineIds, engineMap, {
289
- allowLegacyTmuxNames, extraModels, onReject: reject, onIssue,
325
+ allowLegacyTmuxNames, extraModels, onReject: reject, onIssue, capabilityProfileNames,
290
326
  });
291
327
  if (!cell) return null;
292
328
  if (cellIds.has(cell.id)) return null; // id cell univoco
@@ -301,11 +337,12 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
301
337
  cells.push(cell);
302
338
  }
303
339
 
304
- // `models` compare solo se dichiarato: una configurazione senza modelli
305
- // resta byte-identica a prima, e l'aggiornamento non riscrive nulla.
306
- const parsed = models.length
307
- ? { schemaVersion: SCHEMA_VERSION, engines, cells, models }
308
- : { schemaVersion: SCHEMA_VERSION, engines, cells };
340
+ // `models` e `capabilityProfiles` compaiono solo se dichiarati: una
341
+ // configurazione senza resta byte-identica a prima, e l'aggiornamento non
342
+ // riscrive nulla.
343
+ const parsed = { schemaVersion: SCHEMA_VERSION, engines, cells };
344
+ if (models.length) parsed.models = models;
345
+ if (capabilityProfiles) parsed.capabilityProfiles = capabilityProfiles;
309
346
  // Metadato solo in memoria: serve al bootstrap per rinominare una sessione
310
347
  // legacy PRIMA di persistere il nome safe. Non entra in JSON, draft o API.
311
348
  Object.defineProperty(parsed, 'legacyTmuxSessions', {
@@ -462,8 +499,50 @@ function cellModelAllowed(engineId, model, engineMap, extraModels, onIssue) {
462
499
  return !!normalizeManagedSpec({ ...engine.managed, model }, { extraModels, engineId, onIssue });
463
500
  }
464
501
 
502
+ // Un set di capability (di profilo o di cella): oggetto le cui chiavi ammesse
503
+ // sono CAPABILITY_KEYS, ciascuna un array di nomi VALIDATI PER CHIAVE, senza
504
+ // duplicati, sotto cap. Chiave non ammessa -> rifiuto dell'intero set: come
505
+ // per panelUrl, due esiti opposti che si assomigliano non collassano in uno.
506
+ function parseCapabilitySet(v, onIssue = null) {
507
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
508
+ const out = {};
509
+ for (const key of Object.keys(v)) {
510
+ if (!CAPABILITY_KEYS.includes(key)) return null;
511
+ const arr = v[key];
512
+ if (!Array.isArray(arr) || arr.length > MAX_CAPABILITIES) {
513
+ if (typeof onIssue === 'function') onIssue({ field: `capabilities.${key}`, value: Array.isArray(arr) ? arr.length : -1, rule: `troppi nomi, cap ${MAX_CAPABILITIES}` });
514
+ return null;
515
+ }
516
+ const visti = new Set();
517
+ for (const nome of arr) {
518
+ if (typeof nome !== 'string' || !CAPABILITY_KEY_RES[key].test(nome) || visti.has(nome)) return null;
519
+ visti.add(nome);
520
+ }
521
+ out[key] = [...visti];
522
+ }
523
+ return out;
524
+ }
525
+
526
+ // La superficie effettiva di una cella: il profilo alimenta le chiavi che la
527
+ // cella non copre, e l'override della cella SOSTITUISCE per chiave (unire non
528
+ // saprebbe togliere). LEGACY = nessun campo: la cella di sempre, comportamento
529
+ // invariato. Pura: la vista e i materializzatori la usano senza toccare nulla.
530
+ function effectiveCapabilities(cell, defs) {
531
+ const profili = (defs && defs.capabilityProfiles) || {};
532
+ const profilo = cell && cell.capabilityProfile !== undefined ? profili[cell.capabilityProfile] : undefined;
533
+ const declared = {};
534
+ let any = false;
535
+ for (const key of CAPABILITY_KEYS) {
536
+ const source = (cell && cell.capabilities && cell.capabilities[key] !== undefined) ? cell.capabilities[key]
537
+ : (profilo && profilo[key] !== undefined) ? profilo[key] : undefined;
538
+ if (source !== undefined) { declared[key] = [...source]; any = true; }
539
+ }
540
+ return { legacy: cell?.capabilities === undefined && cell?.capabilityProfile === undefined, declared };
541
+ }
542
+
465
543
  function parseCell(c, engineIds, engineMap = new Map(), {
466
544
  allowLegacyTmuxNames = true, extraModels = null, onReject = null, onIssue = null,
545
+ capabilityProfileNames = null,
467
546
  } = {}) {
468
547
  const reject = (message) => {
469
548
  if (typeof onReject === 'function') onReject(message);
@@ -609,6 +688,26 @@ function parseCell(c, engineIds, engineMap = new Map(), {
609
688
  mcp = [...visti];
610
689
  }
611
690
 
691
+ // capabilities / capabilityProfile: la superficie operativa DICHIARATA della
692
+ // cella. `capabilities` e' un set per chiave (`mcp`/`skills`/`ondemand`,
693
+ // parseCapabilitySet sopra); `capabilityProfile` riferisce un profilo
694
+ // top-level del documento — DEVE esistere: un profilo dangling rifiuta la
695
+ // cella, non collassa in "niente profilo" (stesso motivo di panelUrl).
696
+ // Assenti entrambi = cella legacy: comportamento invariato (effectiveCapabilities).
697
+ let capabilities;
698
+ if (c.capabilities !== undefined) {
699
+ capabilities = parseCapabilitySet(c.capabilities, onIssue);
700
+ if (!capabilities) return null;
701
+ }
702
+ let capabilityProfile;
703
+ if (c.capabilityProfile !== undefined) {
704
+ if (typeof c.capabilityProfile !== 'string' || !CELL_ID_RE.test(c.capabilityProfile)) return null;
705
+ if (!capabilityProfileNames || !capabilityProfileNames.has(c.capabilityProfile)) {
706
+ return reject(`fleet.json rifiutato: la cella ${c.id} riferisce il profilo di capability "${c.capabilityProfile}" che non e' dichiarato in capabilityProfiles`);
707
+ }
708
+ capabilityProfile = c.capabilityProfile;
709
+ }
710
+
612
711
  // label (opzionale): nome LEGGIBILE della cella, distinto dall'id.
613
712
  // L'id resta la chiave stabile con cui si indirizza una cella e con cui si
614
713
  // deriva la sessione tmux; la label e' solo cio' che un umano legge. Senza
@@ -670,6 +769,8 @@ function parseCell(c, engineIds, engineMap = new Map(), {
670
769
  if (prompt !== undefined) out.prompt = prompt;
671
770
  if (panelUrl !== undefined) out.panelUrl = panelUrl;
672
771
  if (mcp !== undefined) out.mcp = mcp;
772
+ if (capabilities !== undefined) out.capabilities = capabilities;
773
+ if (capabilityProfile !== undefined) out.capabilityProfile = capabilityProfile;
673
774
  if (label !== undefined) out.label = label;
674
775
  if (legacyTmuxSession) {
675
776
  Object.defineProperty(out, 'legacyTmuxSession', {
@@ -1098,6 +1199,8 @@ const CAPS = {
1098
1199
 
1099
1200
  module.exports = {
1100
1201
  parseDefinitions,
1202
+ effectiveCapabilities,
1203
+ parseCapabilitySet,
1101
1204
  validateCommandTrust,
1102
1205
  aggiornaDefinizioni,
1103
1206
  proprietarioVivo,