@mmmbuto/nexuscrew 0.9.77 → 0.9.78

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.
@@ -54,6 +54,7 @@ const { createLeaseManager } = require('./cell-lease-server.js');
54
54
  const { createIdentityAuthority } = require('./identity-authority.js');
55
55
  const { MINIMAL_ENV_KEYS, termuxRuntimePaths } = require('../runtime/env.js');
56
56
  const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
57
+ const { requestVlNewSession, vlNewSessionStatus, vlLegacyInventory } = require('./vl-session.js');
57
58
 
58
59
  // Toolkit stateless + runtime estratti (behavior-preserving). I simboli di
59
60
  // launch.js sono re-esportati in module.exports per i test che li importano
@@ -965,7 +966,15 @@ async function createBuiltinFleet(cfg = {}) {
965
966
  // I campi non-identita' che `parseModel` ammette per una dichiarazione:
966
967
  // id ed engine SONO l'identita' (il chiaro per cui esiste edit-model e' non
967
968
  // doverla ritoccare), tutto il resto e' patchabile con la stessa validazione.
968
- const EDIT_MODEL_FIELDS = new Set(['label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning']);
969
+ // `effortLevels` e' fra questi: i livelli dichiarati sono parte della
970
+ // dichiarazione del modello, e dichiararli una volta sola non basta — il
971
+ // cliente vl accetta solo i livelli dichiarati, quindi cambiarli e' una
972
+ // correzione legittima della dichiarazione, non un campo exotic.
973
+ const EDIT_MODEL_FIELDS = new Set(['label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning', 'effortLevels']);
974
+ // Stessa forma del parser (definitions.js, `parseModel`): i livelli sono
975
+ // token corti in minuscolo, e finiscono in un valore d'ambiente, non in argv.
976
+ const EFFORT_LEVELS_MAX = 8;
977
+ const EFFORT_LEVEL_RE = /^[a-z0-9][a-z0-9-]{0,15}$/;
969
978
 
970
979
  async function editModel(id, engine, patch) {
971
980
  if (readonly()) throw httpError(403, 'READONLY: edit-model bloccato');
@@ -997,6 +1006,24 @@ async function createBuiltinFleet(cfg = {}) {
997
1006
  throw httpError(400, `${key} non valido: booleano`);
998
1007
  }
999
1008
  }
1009
+ // `effortLevels`: la dichiarazione dei livelli accettati per questo
1010
+ // modello. `null` = togli la dichiarazione (i livelli tornano a non essere
1011
+ // richiesti); un array = i livelli nuovi. La validazione e' IDENTICA a
1012
+ // quella del parser: un valore che il file rifiuta non entra, e un
1013
+ // livello inventato non viene battezzato "low".
1014
+ if (patch.effortLevels !== undefined && patch.effortLevels !== null) {
1015
+ const levels = patch.effortLevels;
1016
+ if (!Array.isArray(levels) || levels.length < 1 || levels.length > EFFORT_LEVELS_MAX) {
1017
+ throw httpError(400, `effortLevels non valido: array da 1 a ${EFFORT_LEVELS_MAX} livelli`);
1018
+ }
1019
+ const visti = new Set();
1020
+ for (const level of levels) {
1021
+ if (typeof level !== 'string' || !EFFORT_LEVEL_RE.test(level) || visti.has(level)) {
1022
+ throw httpError(400, `effortLevels non valido: livello non conforme o duplicato: ${JSON.stringify(level)}`);
1023
+ }
1024
+ visti.add(level);
1025
+ }
1026
+ }
1000
1027
  const defs = reloadDefs();
1001
1028
  if (!(defs.models || []).some((m) => m.id === wantedId && m.engine === wantedEngine)) {
1002
1029
  throw httpError(404, `modello non dichiarato: ${wantedId} su ${wantedEngine}`);
@@ -1008,7 +1035,13 @@ async function createBuiltinFleet(cfg = {}) {
1008
1035
  // e' la stessa riga del file, l'engine non ha nulla da ricaricare.
1009
1036
  await mutate(defs, (d) => {
1010
1037
  const target = d.models.find((m) => m.id === wantedId && m.engine === wantedEngine);
1011
- Object.assign(target, patch);
1038
+ for (const [key, value] of Object.entries(patch)) {
1039
+ // `null` su `effortLevels` TOGLIE la dichiarazione: e' l'unico modo per
1040
+ // tornare a un modello senza livelli dichiarati (il parser rifiuta un
1041
+ // array vuoto, quindi l'assenza si scrive togliendo il campo).
1042
+ if (key === 'effortLevels' && value === null) { delete target.effortLevels; continue; }
1043
+ target[key] = value;
1044
+ }
1012
1045
  });
1013
1046
  return { ok: true, id: wantedId, engine: wantedEngine, edited: keys };
1014
1047
  }
@@ -1580,6 +1613,15 @@ async function createBuiltinFleet(cfg = {}) {
1580
1613
  baseUrl: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_COMMAND_LEN },
1581
1614
  envKey: { type: 'string', requiredFor: 'provider=custom', pattern: '^[A-Za-z_][A-Za-z0-9_]{0,63}$' },
1582
1615
  providerId: { type: 'string', requiredFor: 'provider=custom', pattern: '^[a-z][a-z0-9_-]{0,31}$' },
1616
+ // Campi vl (client vl): percorsi assoluti sul nodo esecutore, profilo
1617
+ // del catalogo dichiarato, livello effort dichiarato per il modello.
1618
+ // La validazione semantica (esistenza, eseguibile, profilo nel
1619
+ // catalogo, effort nei livelli dichiarati) vive in normalizeManagedSpec
1620
+ // e ai gate di lancio: lo schema dice la FORMA, non la verita' del nodo.
1621
+ vlBinary: { type: 'string', required: false, absolute: true, max: 4096 },
1622
+ vlConfigFile: { type: 'string', required: false, absolute: true, max: 4096 },
1623
+ vlProfile: { type: 'string', required: false, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' },
1624
+ effort: { type: 'string', required: false, pattern: '^[a-z0-9][a-z0-9-]{0,15}$' },
1583
1625
  },
1584
1626
  command: { type: 'string', requiredFor: 'custom', max: CAPS.MAX_COMMAND_LEN, absolute: true },
1585
1627
  args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
@@ -1955,6 +1997,67 @@ async function createBuiltinFleet(cfg = {}) {
1955
1997
 
1956
1998
  async function close() { await launchBroker.close(); if (leaseManager) leaseManager.close(); }
1957
1999
 
2000
+ // --- Sessione nuova esplicita (contratto vl) ------------------------------
2001
+ // Azione SEPARATA dal ciclo di lancio: crea la richiesta persistente che il
2002
+ // prossimo up() della cella consumera' UNA volta (rename atomico, poi --new
2003
+ // alla sola generazione 0). Mai un flag dell'engine, mai un avvio implicito:
2004
+ // l'operatore chiede, il ciclo di lancio applica e consuma. Autorizzata sul
2005
+ // nodo owner perche' il marcatore e lo spawn sono dello stesso nodo.
2006
+ async function vlNewSession(cellId) {
2007
+ if (readonly()) throw httpError(403, 'READONLY: vl-new-session bloccato');
2008
+ const id = String(cellId || '');
2009
+ if (!/^[A-Za-z0-9._-]{1,32}$/.test(id) || id === '.' || id === '..') {
2010
+ throw httpError(400, 'id cell non valido');
2011
+ }
2012
+ const defs = reloadDefs();
2013
+ const cell = findCell(defs, id);
2014
+ if (!cell) throw httpError(400, `cella sconosciuta: ${id}`);
2015
+ const engine = findEngine(defs, cell.engine);
2016
+ if (!engine || !engine.managed || engine.managed.client !== 'vl') {
2017
+ throw httpError(400, `la cella ${id} non usa un engine vl: la sessione nuova esplicita esiste solo per vl`);
2018
+ }
2019
+ try {
2020
+ const requested = requestVlNewSession({ home, cellId: id, now: Date.now() });
2021
+ // Lo stato COMPLETO dopo la richiesta: se sotto c'e' un consumo precedente
2022
+ // non verificato, l'operatore lo vede qui (esito incerto riferito, mai
2023
+ // ripetuto in automatico).
2024
+ return { ok: true, cell: id, ...requested, status: vlNewSessionStatus({ home, cellId: id }) };
2025
+ } catch (e) {
2026
+ throw httpError(500, `richiesta sessione nuova non registrabile: ${e.message}`);
2027
+ }
2028
+ }
2029
+
2030
+ // Vista READ-ONLY dello stato della richiesta di sessione nuova, per una
2031
+ // cella sola. Il contratto e' una domanda su UNA cella: chi non puo'
2032
+ // agire su quella cella non ne legge lo stato (il percorso di scope lo
2033
+ // dichiara come bersaglio, quindi la risposta non porta niente di altre
2034
+ // celle). Niente mutazioni: e' una lettura, e non e' una richiesta nuova.
2035
+ function vlNewSessionStateView(cellId) {
2036
+ const id = String(cellId || '');
2037
+ if (!/^[A-Za-z0-9._-]{1,32}$/.test(id) || id === '.' || id === '..') {
2038
+ throw httpError(400, 'id cell non valido');
2039
+ }
2040
+ const defs = reloadDefs();
2041
+ const cell = findCell(defs, id);
2042
+ if (!cell) throw httpError(400, `cella sconosciuta: ${id}`);
2043
+ const engine = findEngine(defs, cell.engine);
2044
+ if (!engine || !engine.managed || engine.managed.client !== 'vl') {
2045
+ throw httpError(400, `la cella ${id} non usa un engine vl: lo stato della sessione nuova esiste solo per vl`);
2046
+ }
2047
+ // 'pending' = il prossimo lancio ruota; 'consumed' = un lancio ha
2048
+ // consumato la richiesta e l'esito non e' tracciato; 'none' = mai
2049
+ // richiesto. Nomi e timestamp: nessun contenuto di sessione.
2050
+ return { cell: id, ...vlNewSessionStatus({ home, cellId: id }) };
2051
+ }
2052
+
2053
+ // Inventario read-only delle cartelle di stato vl LEGACY (pre-contratto):
2054
+ // elenca quello che esiste, non sposta e non cancella nulla. La migrazione,
2055
+ // se mai ci sara', e' una procedura esplicita separata con backup e scelta
2056
+ // dell'operatore.
2057
+ function vlLegacyInventoryView() {
2058
+ return vlLegacyInventory(home);
2059
+ }
2060
+
1958
2061
  return {
1959
2062
  available: true,
1960
2063
  provider: 'builtin',
@@ -1964,6 +2067,7 @@ async function createBuiltinFleet(cfg = {}) {
1964
2067
  schema, definitions, capabilities,
1965
2068
  credentialStatus, setLocalCredential, removeLocalCredential, testModel, discoverEngineModels,
1966
2069
  defineModel, editModel, removeModel, close,
2070
+ vlNewSession, vlNewSessionState: vlNewSessionStateView, vlLegacyInventory: vlLegacyInventoryView,
1967
2071
  // Fetta 2b: il lease Live esposto alle route /api/lease, che il bridge
1968
2072
  // MCP delle celle consuma per register/refresh/recovery. Assente se il
1969
2073
  // lease e' disattivato: le route rispondono 501, non 500.
@@ -40,6 +40,10 @@ const CODES = [
40
40
  'MCP_CELL_FILE_UNWRITABLE',
41
41
  'CAPABILITY_PROFILE_UNWRITABLE',
42
42
  'CAPABILITY_UNKNOWN_NAME',
43
+ // preflight vl: pausa minima fra due lanci della stessa cella non trascorsa /
44
+ // marcatore «sessione nuova» non consumabile in sicurezza (albero ostile)
45
+ 'VL_LAUNCH_COOLDOWN',
46
+ 'VL_NEW_SESSION_UNSAFE',
43
47
  // launch-broker
44
48
  'LAUNCH_BROKER_UNSAFE',
45
49
  'LAUNCH_BROKER_PAYLOAD',
@@ -147,7 +147,7 @@ function validActivity(value) {
147
147
 
148
148
  function validPayload(payload) {
149
149
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
150
- if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity', 'identityChannel', 'activity'].includes(key))) return false;
150
+ if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity', 'identityChannel', 'activity', 'vlNewSession'].includes(key))) return false;
151
151
  if (typeof payload.command !== 'string' || !payload.command || !Array.isArray(payload.args)) return false;
152
152
  if (!payload.env || typeof payload.env !== 'object' || Array.isArray(payload.env)) return false;
153
153
  // Il launcher decide se il canale identita' esiste (managed.js) e lo dichiara
@@ -155,6 +155,10 @@ function validPayload(payload) {
155
155
  // va quindi ammessa qui, e SOLO come booleano: un valore di altro tipo e' un
156
156
  // payload malformato, non un "canale assente" (assente = caller precedente).
157
157
  if (payload.identityChannel !== undefined && typeof payload.identityChannel !== 'boolean') return false;
158
+ // Stessa regola per la sessione nuova vl: la decisione arriva GIÀ PRESA dal
159
+ // launcher (che ha consumato il marcatore prima dello spawn), quindi qui è un
160
+ // booleano o niente — mai un argv da reinterpretare.
161
+ if (payload.vlNewSession !== undefined && typeof payload.vlNewSession !== 'boolean') return false;
158
162
  return payload.args.every((v) => typeof v === 'string')
159
163
  && Object.entries(payload.env).every(([k, v]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && typeof v === 'string')
160
164
  && validSupervise(payload.supervise)
@@ -767,7 +771,16 @@ async function main(argv = process.argv.slice(2), seams = {}) {
767
771
  const startedAt = now();
768
772
  const childState = { exited: false };
769
773
  const promptCtl = startGenerationPrompt(payload.restartPrompt, generation, childState, seams);
770
- current = spawnImpl(payload.command, payload.args, {
774
+ // Sessione nuova vl (payload.vlNewSession): `--new` va SOLO alla
775
+ // generazione 0 — il primo spawn dopo che il launcher ha consumato la
776
+ // richiesta esplicita. I restart supervisionati (generazione 1+) usano
777
+ // argv pulito: una rotazione ripetuta butterebbe via la sessione appena
778
+ // creata, ed è esattamente ciò che il contratto vieta. Il payload resta
779
+ // immutato: la decisione è del launcher, l'applicazione è per generazione.
780
+ const generationArgs = generation === 0 && payload.vlNewSession === true
781
+ ? [...payload.args, '--new']
782
+ : payload.args;
783
+ current = spawnImpl(payload.command, generationArgs, {
771
784
  env: childEnv,
772
785
  // Senza canale non si creano nemmeno le pipe: fd 3 e 4 chiusi nel
773
786
  // child (non pipe aperte e inutilizzate).
@@ -196,7 +196,7 @@ const MODEL_ID_MAX = 128;
196
196
 
197
197
  function parseModel(m) {
198
198
  if (!m || typeof m !== 'object' || Array.isArray(m)) return null;
199
- const allowed = new Set(['id', 'engine', 'label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning']);
199
+ const allowed = new Set(['id', 'engine', 'label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning', 'effortLevels']);
200
200
  if (Object.keys(m).some((k) => !allowed.has(k))) return null;
201
201
  const id = typeof m.id === 'string' ? m.id.trim() : '';
202
202
  // Stessa forma accettata dal gate dei modelli: nessun byte di controllo, un
@@ -230,6 +230,20 @@ function parseModel(m) {
230
230
  if (typeof m[key] !== 'boolean') return null;
231
231
  out[key] = m[key];
232
232
  }
233
+ // `effortLevels`: the effort levels DECLARED for this model (client vl, env
234
+ // VL_EFFORT). The runtime accepts only declared levels — no universal
235
+ // low/medium/high equivalence is assumed — so an absent declaration means
236
+ // "no effort can be requested for this model", never "any level goes".
237
+ // Short lowercase tokens only: they land in an env value, not in argv.
238
+ if (m.effortLevels !== undefined) {
239
+ if (!Array.isArray(m.effortLevels) || !m.effortLevels.length || m.effortLevels.length > 8) return null;
240
+ const seen = new Set();
241
+ for (const level of m.effortLevels) {
242
+ if (typeof level !== 'string' || !/^[a-z0-9][a-z0-9-]{0,15}$/.test(level) || seen.has(level)) return null;
243
+ seen.add(level);
244
+ }
245
+ out.effortLevels = [...m.effortLevels];
246
+ }
233
247
  return out;
234
248
  }
235
249