@mmmbuto/nexuscrew 0.9.14 → 0.9.16

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,8 +11,8 @@
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-9jCxHZwQ.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-keXh4CAm.css">
14
+ <script type="module" crossorigin src="/assets/index-CwsGpQwK.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CprIQlC5.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.14"}
1
+ {"version":"0.9.16"}
@@ -2,7 +2,7 @@
2
2
  // lib/audio/receipt.js — receipt degli enunciati, bounded e ridotto all'osso.
3
3
  //
4
4
  // Scope del chiamante = NODO + CELLA. Non la sola cella: due nodi possono avere
5
- // celle omonime (`Dev` esiste su piu' installazioni), e una chiave basata solo
5
+ // celle omonime (`Alpha` esiste su piu' installazioni), e una chiave basata solo
6
6
  // sul nome permetterebbe a un nodo di leggere o sovrascrivere i receipt di una
7
7
  // cella altrui con lo stesso nome. La chiave e' costruita qui, dal server, mai
8
8
  // accettata dal client.
@@ -68,7 +68,7 @@ function validIdentity(value) {
68
68
  && isValidSession(value.tmuxSession);
69
69
  }
70
70
 
71
- function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now = () => Date.now() }) {
71
+ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now = () => Date.now(), diagnostics = null }) {
72
72
  const r = express.Router();
73
73
 
74
74
  async function status() {
@@ -96,31 +96,47 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
96
96
  r.post('/send', express.json({ limit: '16kb' }), async (req, res) => {
97
97
  if (readonly()) return res.status(403).json({ error: 'READONLY: invio cella bloccato' });
98
98
  const body = req.body || {};
99
+ // Observability (2026-08-28): chi manda, a chi, con quale id e come finisce.
100
+ // SOLO metadati: il testo del messaggio non tocca mai la diagnostica.
101
+ const logSend = (level, event, reason) => {
102
+ if (!diagnostics) return;
103
+ try {
104
+ diagnostics.record(level, 'cell-msg', event, reason
105
+ ? `Invio cella: ${reason}`
106
+ : 'Invio cella consegnato alla sessione target', {
107
+ fromCell: body.from && body.from.cell,
108
+ toCell: body.to && body.to.cell,
109
+ msgId: typeof body.id === 'string' ? body.id : undefined,
110
+ reason: reason ? String(reason).slice(0, 48) : undefined,
111
+ });
112
+ } catch (_) {}
113
+ };
114
+ const reject = (code, reason) => { logSend('warn', 'CELL_MESSAGE_REJECTED', reason); return res.status(code).json({ error: reason }); };
99
115
  const keys = Object.keys(body);
100
116
  if (keys.some((key) => !['id', 'from', 'to', 'message'].includes(key))
101
117
  || !MESSAGE_ID_RE.test(String(body.id || ''))
102
118
  || !validIdentity(body.from) || !validIdentity(body.to)
103
119
  || !submitTextOk(body.message)) {
104
- return res.status(400).json({ error: 'messaggio cella non valido' });
120
+ return reject(400, 'messaggio cella non valido');
105
121
  }
106
122
  const localId = instanceId();
107
123
  if (!NODE_ID_RE.test(String(localId || '')) || body.to.instanceId !== localId) {
108
- return res.status(409).json({ error: 'destinazione non appartiene a questo nodo' });
124
+ return reject(409, 'destinazione non appartiene a questo nodo');
109
125
  }
110
126
  const visited = parseVisited(req);
111
127
  if (visited === null || (visited.length && (visited.at(-1) !== localId
112
128
  || body.from.instanceId !== visited[0]))) {
113
- return res.status(403).json({ error: 'identita mittente non verificata' });
129
+ return reject(403, 'identita mittente non verificata');
114
130
  }
115
131
  if (!visited.length && body.from.instanceId !== localId) {
116
- return res.status(403).json({ error: 'mittente remoto senza route autenticata' });
132
+ return reject(403, 'mittente remoto senza route autenticata');
117
133
  }
118
134
  try {
119
135
  const cells = publicCells(await status(), localId, now());
120
136
  const target = cells.find((cell) => cell.cell === body.to.cell
121
137
  && cell.tmuxSession === body.to.tmuxSession);
122
- if (!target) return res.status(404).json({ error: 'cella destinataria sconosciuta' });
123
- if (!target.canReceive) return res.status(409).json({ error: 'cella destinataria non attiva' });
138
+ if (!target) return reject(404, 'cella destinataria sconosciuta');
139
+ if (!target.canReceive) return reject(409, 'cella destinataria non attiva');
124
140
  const label = `${body.from.cell}@${body.from.instanceId.slice(0, 8)}`;
125
141
  // End on printable text even when the source message ends in a newline:
126
142
  // Pi may auto-submit a bracketed paste that ends with LF. NexusCrew owns
@@ -128,8 +144,9 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
128
144
  const envelope = `[NexusCrew message ${body.id} from ${label}]\n${body.message}\n[End NexusCrew message]`;
129
145
  const outcome = await submit(target.tmuxSession, envelope, { engine: target.engine });
130
146
  if (!outcome || outcome.submitted !== true) {
131
- return res.status(409).json({ error: outcome?.reason || 'consegna non riuscita' });
147
+ return reject(409, outcome?.reason || 'consegna non riuscita');
132
148
  }
149
+ logSend('notice', 'CELL_MESSAGE_SENT');
133
150
  const at = now();
134
151
  return res.json({
135
152
  id: body.id,
@@ -138,7 +155,7 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
138
155
  to: { instanceId: localId, cell: target.cell, tmuxSession: target.tmuxSession },
139
156
  note: 'submitted conferma solo paste+Enter nel TUI, non elaborazione o completamento',
140
157
  });
141
- } catch (e) { return res.status(500).json({ error: String(e.message || e) }); }
158
+ } catch (e) { return reject(500, String(e.message || e).slice(0, 96)); }
142
159
  });
143
160
 
144
161
  r.use((err, _req, res, _next) => {
package/lib/config.js CHANGED
@@ -67,6 +67,14 @@ function baseDefaults() {
67
67
  // MC1.5: limite dichiarato per OGNI fase del ponte (GET designazione e
68
68
  // sessione sul socket). Oltre questo la Live parte senza puntamento.
69
69
  liveBridgeTimeoutMs: 1500,
70
+ // PTY grace is finite and configurable for mobile handovers. The caps keep
71
+ // a small host from retaining an unbounded number of disconnected sessions.
72
+ ptyGraceMs: 30000,
73
+ ptyGraceMaxSessions: 8,
74
+ ptyGraceMaxMemoryBytes: 8 * 1024,
75
+ // Un singolo timeout del probe può essere jitter mobile: servono tre
76
+ // fallimenti consecutivi prima di dichiarare il peer irraggiungibile.
77
+ nodeHealthFailureThreshold: 3,
70
78
  };
71
79
  }
72
80
 
@@ -119,6 +127,10 @@ function envOverrides() {
119
127
  }
120
128
  if (process.env.NEXUSCREW_LIVE_BRIDGE_SOCKET) e.liveBridgeSocketPath = process.env.NEXUSCREW_LIVE_BRIDGE_SOCKET;
121
129
  if (process.env.NEXUSCREW_LIVE_BRIDGE_TIMEOUT_MS) e.liveBridgeTimeoutMs = Number(process.env.NEXUSCREW_LIVE_BRIDGE_TIMEOUT_MS);
130
+ if (process.env.NEXUSCREW_PTY_GRACE_MS) e.ptyGraceMs = Number(process.env.NEXUSCREW_PTY_GRACE_MS);
131
+ if (process.env.NEXUSCREW_PTY_GRACE_MAX_SESSIONS) e.ptyGraceMaxSessions = Number(process.env.NEXUSCREW_PTY_GRACE_MAX_SESSIONS);
132
+ if (process.env.NEXUSCREW_PTY_GRACE_MAX_MEMORY_BYTES) e.ptyGraceMaxMemoryBytes = Number(process.env.NEXUSCREW_PTY_GRACE_MAX_MEMORY_BYTES);
133
+ if (process.env.NEXUSCREW_NODE_HEALTH_FAILURE_THRESHOLD) e.nodeHealthFailureThreshold = Number(process.env.NEXUSCREW_NODE_HEALTH_FAILURE_THRESHOLD);
122
134
  return e;
123
135
  }
124
136
 
@@ -4,10 +4,13 @@ const DEFAULT_MAX_RECORDS = 500;
4
4
  const DEFAULT_MAX_BYTES = 256 * 1024;
5
5
  const DEFAULT_MAX_ENTRY_BYTES = 4096;
6
6
  const ALLOWED_DURATIONS = new Set([300, 900, 1800, 3600]);
7
- const LEVELS = new Set(['debug', 'info', 'warn', 'error']);
7
+ const LEVELS = new Set(['debug', 'info', 'notice', 'warn', 'error']);
8
8
  const META_KEYS = new Set([
9
9
  'port', 'platform', 'reason', 'durationSeconds', 'count', 'errno', 'client',
10
10
  'cell', 'engine', 'action', 'state', 'transport', 'phase', 'version', 'status', 'node', 'code',
11
+ // Observability (2026-08-28): metadati di routing e chiusure — identita di
12
+ // cella e id di messaggio, MAI contenuto (DENIED_KEY resta la seconda rete).
13
+ 'fromCell', 'toCell', 'msgId', 'sessionId', 'durationMs', 'gapMs', 'closeCode', 'exitCode', 'drops',
11
14
  ]);
12
15
  const DENIED_KEY = /(authorization|cookie|token|secret|credential|password|prompt|terminal|argv|command|env|content|payload|file|path|endpoint|url)/i;
13
16
 
@@ -84,23 +84,32 @@ function draftFrom(defs) {
84
84
  };
85
85
  }
86
86
 
87
+ function warnEngineCap(log, engineId, count) {
88
+ const emit = typeof log === 'function' ? log : console.warn;
89
+ emit(`WARN fleet backfill: engine ${engineId} non aggiunto: ${count} engine dichiarati, cap ${CAPS.MAX_ENGINES} raggiunto; riduci gli engine prima di riprovare`);
90
+ }
91
+
87
92
  // Upgrade locale, idempotente e non distruttivo: installazioni gia' esistenti
88
93
  // ricevono l'engine standard Shell senza riscrivere celle o sostituire un id
89
94
  // scelto dall'utente. Se lo store e' pieno o la scrittura non e' possibile, il
90
95
  // bootstrap resta utilizzabile con le definizioni precedenti.
91
96
  function backfillShellEngine(defsPath, defs, log) {
92
97
  if (!defs) return defs;
98
+ const emit = typeof log === 'function' ? log : console.warn;
93
99
  // Le condizioni si valutano su cio' che si legge DENTRO il lock, non
94
100
  // sullo stato che avevamo in mano: e' la differenza fra decidere sul
95
101
  // presente e decidere su una fotografia.
96
102
  const esito = aggiornaDefinizioni(defsPath, (dentro) => {
97
103
  if (dentro.engines.some((engine) => engine.managed?.client === 'shell')) return null;
98
104
  if (dentro.engines.some((engine) => engine.id === 'shell.local')) return null;
99
- if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
105
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) {
106
+ warnEngineCap(emit, 'shell.local', dentro.engines.length);
107
+ return null;
108
+ }
100
109
  const draft = draftFrom(dentro);
101
110
  draft.engines.push(defaultShellEngine());
102
111
  return draft;
103
- }, { log });
112
+ }, { log: emit });
104
113
  return esito || defs;
105
114
  }
106
115
 
@@ -115,6 +124,7 @@ function backfillShellEngine(defsPath, defs, log) {
115
124
  // avviene solo nel field gate esplicito del coordinator).
116
125
  function backfillAgyEngine(defsPath, defs, cfg = {}) {
117
126
  if (!defs) return defs;
127
+ const emit = typeof cfg.log === 'function' ? cfg.log : console.warn;
118
128
  const platform = cfg.platform || process.platform;
119
129
  const termux = platform === 'android'
120
130
  || termuxRuntimePaths(cfg.env || process.env, { platform, home: cfg.home }) !== null;
@@ -124,11 +134,14 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
124
134
  const esito = aggiornaDefinizioni(defsPath, (dentro) => {
125
135
  if (dentro.engines.some((engine) => engine.managed?.client === 'agy')) return null;
126
136
  if (dentro.engines.some((engine) => engine.id === 'agy.native')) return null;
127
- if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
137
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) {
138
+ warnEngineCap(emit, 'agy.native', dentro.engines.length);
139
+ return null;
140
+ }
128
141
  const draft = draftFrom(dentro);
129
142
  draft.engines.push(defaultAgyEngine());
130
143
  return draft;
131
- }, { log: cfg.log });
144
+ }, { log: emit });
132
145
  return esito || defs;
133
146
  }
134
147
 
@@ -238,16 +251,20 @@ function defaultDesktopEngine() {
238
251
  // — e' il posto dove qualcuno sa gia' che il container esiste.
239
252
  function backfillDesktopEngine(defsPath, defs, log) {
240
253
  if (!defs) return defs;
254
+ const emit = typeof log === 'function' ? log : console.warn;
241
255
  if (defs.engines.some((engine) => engine.id === 'desktop.local')) {
242
256
  return riparaDesktopEngine(defsPath, defs, log);
243
257
  }
244
258
  const esito = aggiornaDefinizioni(defsPath, (dentro) => {
245
259
  if (dentro.engines.some((engine) => engine.id === 'desktop.local')) return null;
246
- if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
260
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) {
261
+ warnEngineCap(emit, 'desktop.local', dentro.engines.length);
262
+ return null;
263
+ }
247
264
  const draft = draftFrom(dentro);
248
265
  draft.engines.push(defaultDesktopEngine());
249
266
  return draft;
250
- }, { log });
267
+ }, { log: emit });
251
268
  return esito || defs;
252
269
  }
253
270
 
@@ -322,17 +339,21 @@ function eDaRiparare(defs) {
322
339
  // invariato), rispetta il cap MAX_ENGINES. Non tocca CELLE.
323
340
  function backfillKimiEngine(defsPath, defs, log) {
324
341
  if (!defs) return defs;
342
+ const emit = typeof log === 'function' ? log : console.warn;
325
343
  // Le condizioni si valutano su cio' che si legge DENTRO il lock, non
326
344
  // sullo stato che avevamo in mano: e' la differenza fra decidere sul
327
345
  // presente e decidere su una fotografia.
328
346
  const esito = aggiornaDefinizioni(defsPath, (dentro) => {
329
347
  if (dentro.engines.some((engine) => engine.managed?.client === 'kimi')) return null;
330
348
  if (dentro.engines.some((engine) => engine.id === 'kimi.native')) return null;
331
- if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
349
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) {
350
+ warnEngineCap(emit, 'kimi.native', dentro.engines.length);
351
+ return null;
352
+ }
332
353
  const draft = draftFrom(dentro);
333
354
  draft.engines.push(defaultKimiEngine());
334
355
  return draft;
335
- }, { log });
356
+ }, { log: emit });
336
357
  return esito || defs;
337
358
  }
338
359
 
@@ -345,6 +366,7 @@ function backfillKimiEngine(defsPath, defs, log) {
345
366
  // MAX_ENGINES. Non tocca CELLE.
346
367
  function backfillGrokEngine(defsPath, defs, cfg = {}) {
347
368
  if (!defs) return defs;
369
+ const emit = typeof cfg.log === 'function' ? cfg.log : console.warn;
348
370
  const platform = cfg.platform || process.platform;
349
371
  const termux = platform === 'android'
350
372
  || termuxRuntimePaths(cfg.env || process.env, { platform, home: cfg.home }) !== null;
@@ -354,11 +376,14 @@ function backfillGrokEngine(defsPath, defs, cfg = {}) {
354
376
  const esito = aggiornaDefinizioni(defsPath, (dentro) => {
355
377
  if (dentro.engines.some((engine) => engine.managed?.client === 'grok')) return null;
356
378
  if (dentro.engines.some((engine) => engine.id === 'grok.native')) return null;
357
- if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
379
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) {
380
+ warnEngineCap(emit, 'grok.native', dentro.engines.length);
381
+ return null;
382
+ }
358
383
  const draft = draftFrom(dentro);
359
384
  draft.engines.push(defaultGrokEngine());
360
385
  return draft;
361
- }, { log: cfg.log });
386
+ }, { log: emit });
362
387
  return esito || defs;
363
388
  }
364
389
 
@@ -372,17 +397,21 @@ function backfillGrokEngine(defsPath, defs, cfg = {}) {
372
397
  // MAX_ENGINES. Non tocca CELLE.
373
398
  function backfillVlEngine(defsPath, defs, log) {
374
399
  if (!defs) return defs;
400
+ const emit = typeof log === 'function' ? log : console.warn;
375
401
  // Le condizioni si valutano su cio' che si legge DENTRO il lock, non
376
402
  // sullo stato che avevamo in mano: e' la differenza fra decidere sul
377
403
  // presente e decidere su una fotografia.
378
404
  const esito = aggiornaDefinizioni(defsPath, (dentro) => {
379
405
  if (dentro.engines.some((engine) => engine.managed?.client === 'vl')) return null;
380
406
  if (dentro.engines.some((engine) => engine.id === 'vl.native')) return null;
381
- if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
407
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) {
408
+ warnEngineCap(emit, 'vl.native', dentro.engines.length);
409
+ return null;
410
+ }
382
411
  const draft = draftFrom(dentro);
383
412
  draft.engines.push(defaultVlEngine());
384
413
  return draft;
385
- }, { log });
414
+ }, { log: emit });
386
415
  return esito || defs;
387
416
  }
388
417
 
@@ -576,9 +605,16 @@ async function createBuiltinFleet(cfg = {}) {
576
605
  // fallito, non che ci sia stata un'eccezione.
577
606
  const bootStat = {};
578
607
  let boot = loadDefinitions(defsPath, bootStat);
579
- if (!boot) return bootStat.lstatBlocked
580
- ? { ...off, reason: `fleet.json non verificabile (${bootStat.lstatBlocked}): fail-closed, non "assente" né "invalido"` }
581
- : off;
608
+ if (!boot) {
609
+ if (bootStat.parseReason) {
610
+ const emit = typeof cfg.log === 'function' ? cfg.log : console.warn;
611
+ emit(`WARN fleet definitions: ${bootStat.parseReason}`);
612
+ return { ...off, reason: bootStat.parseReason };
613
+ }
614
+ return bootStat.lstatBlocked
615
+ ? { ...off, reason: `fleet.json non verificabile (${bootStat.lstatBlocked}): fail-closed, non "assente" né "invalido"` }
616
+ : off;
617
+ }
582
618
 
583
619
  if (!readonly()) {
584
620
  // Audit 093, rilievo 3: lo snapshot della BASE si prende QUI, alla lettura
@@ -1014,8 +1050,8 @@ async function createBuiltinFleet(cfg = {}) {
1014
1050
  throw httpError(400, 'modello senza id o engine');
1015
1051
  }
1016
1052
  }
1017
- if (!Array.isArray(engines) || engines.length < 1 || engines.length > 24) {
1018
- throw httpError(400, 'engines deve contenere 1..24 definizioni');
1053
+ if (!Array.isArray(engines) || engines.length < 1 || engines.length > CAPS.MAX_ENGINES) {
1054
+ throw httpError(400, `engines deve contenere 1..${CAPS.MAX_ENGINES} definizioni`);
1019
1055
  }
1020
1056
  const allowed = new Set(['id', 'label', 'rc', 'managed', 'command', 'args', 'envKeys', 'model', 'promptMode', 'promptFlag']);
1021
1057
  const seen = new Set();
@@ -140,9 +140,9 @@
140
140
  "supports_search_tool": false
141
141
  },
142
142
  {
143
- "slug": "grok-4.5",
144
- "display_name": "grok-4.5",
145
- "description": "OpenCode Go: grok-4.5",
143
+ "slug": "grok-4.6",
144
+ "display_name": "grok-4.6",
145
+ "description": "OpenCode Go: grok-4.6",
146
146
  "default_reasoning_level": "medium",
147
147
  "supported_reasoning_levels": [
148
148
  {
@@ -13,9 +13,10 @@ const path = require('node:path');
13
13
  const crypto = require('node:crypto');
14
14
  const { normalizeManagedSpec } = require('./managed.js');
15
15
 
16
- // --- Cap + identita' (dichiarati; ragionevoli per un file di flotta locale) ---
16
+ // --- Cap + identita' (dichiarati; 100 regge una flotta reale con margine;
17
+ // il limite resta deliberato contro un file patologico) ---
17
18
  const SCHEMA_VERSION = 1;
18
- const MAX_ENGINES = 24;
19
+ const MAX_ENGINES = 100;
19
20
  const MAX_CELLS = 32;
20
21
  const MAX_ARGS = 32; // argv: array, mai stringa spezzata (no shell)
21
22
  const MAX_ARG_LEN = 1024; // 1 KB per arg
@@ -204,7 +205,11 @@ function parseModel(m) {
204
205
  // parseDefinitions(raw) -> {schemaVersion, engines, cells, models} | null
205
206
  // Accetta stringa JSON o oggetto gia' parsato. Strict + fail-closed.
206
207
  // ---------------------------------------------------------------------------
207
- function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
208
+ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null } = {}) {
209
+ const reject = (message) => {
210
+ if (typeof onReject === 'function') onReject(message);
211
+ return null;
212
+ };
208
213
  try {
209
214
  let d;
210
215
  if (typeof raw === 'string') {
@@ -217,7 +222,9 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
217
222
 
218
223
  if (d.schemaVersion !== SCHEMA_VERSION) return null;
219
224
  if (!Array.isArray(d.engines)) return null; // engines obbligatorio (array)
220
- if (d.engines.length > MAX_ENGINES) return null;
225
+ if (d.engines.length > MAX_ENGINES) {
226
+ return reject(`fleet.json rifiutato: ${d.engines.length} engine dichiarati, cap ${MAX_ENGINES}; riduci gli engine a ${MAX_ENGINES} o meno`);
227
+ }
221
228
  if (!Array.isArray(d.cells)) return null; // cells obbligatorio (array)
222
229
  if (d.cells.length > MAX_CELLS) return null;
223
230
 
@@ -269,7 +276,7 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
269
276
  const cellIds = new Set();
270
277
  const cells = [];
271
278
  for (const c of d.cells) {
272
- const cell = parseCell(c, engineIds, engineMap, { allowLegacyTmuxNames, extraModels });
279
+ const cell = parseCell(c, engineIds, engineMap, { allowLegacyTmuxNames, extraModels, onReject: reject });
273
280
  if (!cell) return null;
274
281
  if (cellIds.has(cell.id)) return null; // id cell univoco
275
282
  cellIds.add(cell.id);
@@ -430,7 +437,11 @@ function cellModelAllowed(engineId, model, engineMap, extraModels) {
430
437
  return !!normalizeManagedSpec({ ...engine.managed, model }, { extraModels });
431
438
  }
432
439
 
433
- function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames = true, extraModels = null } = {}) {
440
+ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames = true, extraModels = null, onReject = null } = {}) {
441
+ const reject = (message) => {
442
+ if (typeof onReject === 'function') onReject(message);
443
+ return null;
444
+ };
434
445
  if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
435
446
 
436
447
  // id
@@ -475,7 +486,9 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
475
486
  if (c.models !== undefined) {
476
487
  if (!c.models || typeof c.models !== 'object' || Array.isArray(c.models)) return null;
477
488
  const entries = Object.entries(c.models);
478
- if (entries.length > MAX_ENGINES) return null;
489
+ if (entries.length > MAX_ENGINES) {
490
+ return reject(`fleet.json rifiutato: la cella ${c.id} contiene ${entries.length} modelli ricordati, cap ${MAX_ENGINES}; riduci la mappa a ${MAX_ENGINES} o meno`);
491
+ }
479
492
  for (const [engineId, value] of entries) {
480
493
  if (!engineIds.has(engineId) || typeof value !== 'string' || !value || value.length > MAX_MODEL_VAL_LEN) return null;
481
494
  // Anche la memoria per-engine passa dal gate: e' un valore che tornera'
@@ -494,7 +507,9 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
494
507
  if (c.permissionPolicies !== undefined) {
495
508
  if (!c.permissionPolicies || typeof c.permissionPolicies !== 'object' || Array.isArray(c.permissionPolicies)) return null;
496
509
  const entries = Object.entries(c.permissionPolicies);
497
- if (entries.length > MAX_ENGINES) return null;
510
+ if (entries.length > MAX_ENGINES) {
511
+ return reject(`fleet.json rifiutato: la cella ${c.id} contiene ${entries.length} permissionPolicies, cap ${MAX_ENGINES}; riduci la mappa a ${MAX_ENGINES} o meno`);
512
+ }
498
513
  permissionPolicies = {};
499
514
  for (const [engineId, value] of entries) {
500
515
  if (!engineIds.has(engineId)) return null;
@@ -511,7 +526,9 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
511
526
  if (c.commands !== undefined) {
512
527
  if (!c.commands || typeof c.commands !== 'object' || Array.isArray(c.commands)) return null;
513
528
  const entries = Object.entries(c.commands);
514
- if (entries.length > MAX_ENGINES) return null;
529
+ if (entries.length > MAX_ENGINES) {
530
+ return reject(`fleet.json rifiutato: la cella ${c.id} contiene ${entries.length} commands, cap ${MAX_ENGINES}; riduci la mappa a ${MAX_ENGINES} o meno`);
531
+ }
515
532
  commands = {};
516
533
  for (const [engineId, value] of entries) {
517
534
  if (!engineIds.has(engineId) || engineMap.get(engineId)?.managed?.client !== 'shell') return null;
@@ -980,7 +997,9 @@ function loadDefinitions(p, out) {
980
997
  if (st.isSymbolicLink()) return null; // no symlink
981
998
  if (!st.isFile()) return null;
982
999
  const raw = fs.readFileSync(p, 'utf8');
983
- return parseDefinitions(raw);
1000
+ return parseDefinitions(raw, {
1001
+ onReject: out ? (reason) => { out.parseReason = reason; } : null,
1002
+ });
984
1003
  } catch (_) { return null; }
985
1004
  }
986
1005
 
@@ -13,12 +13,57 @@ const { readCredentialStore, safePrivateDir } = require('./credentials.js');
13
13
  const OLLAMA_CLOUD_MODELS = Object.freeze([
14
14
  'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
15
15
  'qwen3.5:397b', 'deepseek-v4-flash', 'mistral-large-3:675b', 'gemma4:31b',
16
+ // glm-5.3-flash: disponibile su ollama.com/library (2026-08-27; glm-5.3
17
+ // non-flash invece 404 su Ollama e NON va elencata qui). Gia` usata da
18
+ // celle esistenti su questo engine: senza voce in OLLAMA_CONTEXT il launch
19
+ // ricadeva sul fallback 200000 in silenzio.
20
+ 'glm-5.3-flash',
16
21
  ]);
22
+ // Autorita' per OLLAMA_CONTEXT = IL CAMPO STRUTTURATO «Context» della scheda
23
+ // canale (ollama.com/library, fetch 2026-08-27) — NON la descrizione, che
24
+ // racconta il modello in astratto (su minimax-m3 dice 1M mentre il campo dice
25
+ // 512K). models.dev vale dove la riga provider esiste ed e' aggiornata.
26
+ // Criterio suffissi: numero esatto disponibile vince (glm-5.2 976K -> 976000;
27
+ // glm-5.3-flash 1M corroborato 1000000 da 7 provider su models.dev);
28
+ // altrimenti convenzione binaria del repo (deepseek 1M -> 1048576;
29
+ // minimax 512K -> 524288, valore pre-esistente ripristinato).
17
30
  const OLLAMA_CONTEXT = Object.freeze({
18
- 'glm-5.2': 1000000, 'kimi-k2.7-code': 262144, 'deepseek-v4-pro': 524288,
19
- 'minimax-m3': 524288, 'qwen3.5:397b': 262144, 'deepseek-v4-flash': 1048576,
31
+ // glm-5.2: scheda «976K context» (era 1M: over-declare di 24k).
32
+ 'glm-5.2': 976000, 'kimi-k2.7-code': 262144,
33
+ // deepseek-v4-pro: scheda «1M context» (era 524288: META' del reale).
34
+ 'deepseek-v4-pro': 1048576,
35
+ 'minimax-m3': 524288,
36
+ 'qwen3.5:397b': 262144, 'deepseek-v4-flash': 1048576,
20
37
  'mistral-large-3:675b': 262144, 'gemma4:31b': 262144,
38
+ // glm-5.3-flash: scheda «1M context» + concorde zhipuai/zai/opencode-go.
39
+ 'glm-5.3-flash': 1000000,
21
40
  });
41
+ // Capacita' per modello OSSERVATE dalla scheda canale (ollama.com/library,
42
+ // 2026-08-27). Assenza = default conservativo (comportamento odierno, nessuna
43
+ // regressione). glm-5.3-flash e' nativamente multimodale con tools e thinking:
44
+ // senza queste voci il catalogo generato spegne vision e parallel tools anche
45
+ // a finestra corretta (il gating Image e' su model_info.input_modalities).
46
+ const OLLAMA_MODEL_CAPABILITIES = Object.freeze({
47
+ // vision/thinking/tools: dichiarati dalla scheda. PARALLEL NON dichiarato:
48
+ // default conservativo false finche' non misurato su device (i tool restano
49
+ // attivi; resta spento solo l'invocarli in parallelo). Accendere dopo smoke.
50
+ 'glm-5.3-flash': Object.freeze({ input: ['text', 'image'], reasoning: true, supportsParallelToolCalls: false }),
51
+ });
52
+ // Descrittori dell'engine ollama-cloud per la generazione del catalogo client:
53
+ // stessa forma dei descrittori custom/PI (id, label, contextWindow, input,
54
+ // reasoning, supportsParallelToolCalls).
55
+ function ollamaCloudCatalogModels() {
56
+ return OLLAMA_CLOUD_MODELS.map((id) => {
57
+ const cap = OLLAMA_MODEL_CAPABILITIES[id] || {};
58
+ return Object.freeze({
59
+ id, label: id,
60
+ contextWindow: ollamaContextFor(id),
61
+ input: Object.freeze([...(cap.input || ['text'])]),
62
+ reasoning: cap.reasoning === true,
63
+ supportsParallelToolCalls: cap.supportsParallelToolCalls === true,
64
+ });
65
+ });
66
+ }
22
67
  // Lo stesso modello si scrive con o senza tag ('deepseek-v4-flash' e
23
68
  // 'deepseek-v4-flash:0731'). Si prova prima la chiave esatta, perche' nella
24
69
  // mappa ci sono nomi in cui il tag fa parte dell'identita' del modello
@@ -151,7 +196,9 @@ const ALIBABA_PI_MODELS = Object.freeze([
151
196
  // xiaomi/mimo-v2.5*"). hy3-preview: "Model is unavailable".
152
197
  // Il catalogo live li pubblicizza comunque; qui non entrano.
153
198
  // - grok-4.5 solo su Responses: su Chat risponde 503 e Messages lo rifiuta
154
- // esplicitamente ("not supported for format anthropic").
199
+ // esplicitamente ("not supported for format anthropic"). Dal 2026-08-27
200
+ // l'id in elenco e` grok-4.6 (stessi limiti misurati su models.dev); il
201
+ // commento sopra conserva la misura storica fatta su 4.5.
155
202
  // - deepseek-v4-flash-vision-exp: MISURATO 2026-08-24 sul gateway
156
203
  // opencode.ai/zen/go -> 200 su tutti e tre i wire (Responses
157
204
  // status=completed, Messages stop_reason=end_turn, Chat finish_reason=stop).
@@ -168,7 +215,7 @@ const OPENCODE_GO_MESSAGES_MODELS = Object.freeze([
168
215
  'qwen3.8-max', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.5-plus',
169
216
  ]);
170
217
  const OPENCODE_GO_RESPONSES_MODELS = Object.freeze([
171
- 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro', 'gpt-5.6-luna', 'grok-4.5', 'glm-5.2', 'glm-5.1', 'glm-5',
218
+ 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro', 'gpt-5.6-luna', 'grok-4.6', 'glm-5.2', 'glm-5.1', 'glm-5',
172
219
  ]);
173
220
  const OPENCODE_GO_CHAT_MODELS = Object.freeze([
174
221
  'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro', 'glm-5.2', 'glm-5.1', 'glm-5',
@@ -212,7 +259,9 @@ const OPENCODE_GO_LIMITS = Object.freeze({
212
259
  'mimo-v2.5-pro': { context: 1048576, output: 128000 },
213
260
  hy3: { context: 256000, output: 64000 },
214
261
  'gpt-5.6-luna': { context: 1050000, output: 128000 },
215
- 'grok-4.5': { context: 500000, output: 500000 },
262
+ // grok-4.5 -> grok-4.6 (2026-08-27): id aggiornato al modello reale;
263
+ // limiti confermati da models.dev (xai e opencode-go: 500000/500000).
264
+ 'grok-4.6': { context: 500000, output: 500000 },
216
265
  });
217
266
 
218
267
  function opencodeGoContextFor(model) {
@@ -296,7 +345,7 @@ const CATALOG = Object.freeze([
296
345
  // deve dire la verita' su cosa stiamo usando. Il suffisso `[1m]` resta: e' un
297
346
  // flag di finestra del CLI e viene tolto prima della richiesta HTTP, dove
298
347
  // `glm-5.3[1m]` letterale darebbe 400 (code 1214, misurato).
299
- { id: 'claude.zai', client: 'claude', provider: 'zai', label: 'Z.AI', auth: 'dynamic', credentialEnv: true, defaultEnvKey: 'ZAI_API_KEY', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.3[1m]', models: ['glm-5.3[1m]', 'glm-5.2[1m]'], core: true },
348
+ { id: 'claude.zai', client: 'claude', provider: 'zai', label: 'Z.AI', auth: 'dynamic', credentialEnv: true, defaultEnvKey: 'ZAI_API_KEY', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.3[1m]', models: ['glm-5.3[1m]', 'glm-5.2[1m]', 'glm-5.3-flash'], core: true },
300
349
  // OpenCode Go su Claude parla Anthropic Messages, e la wire accetta SOLO
301
350
  // `x-api-key`: con `Authorization: Bearer` risponde 401 AuthError. Per questo
302
351
  // l'endpoint e' la root senza `/v1` (il client aggiunge `/v1/messages`) e il
@@ -433,8 +482,8 @@ const CATALOG = Object.freeze([
433
482
  // --- Sezione legacy --------------------------------------------------------
434
483
  // Compatibilita' sola lettura/launch per configurazioni 0.8.0: mai nel catalogo
435
484
  // UI (publicCatalog filtra `legacy`). Risolti solo da profileFor/normalizeManagedSpec.
436
- { id: 'claude.zai-a', client: 'claude', provider: 'zai', credentialProfile: 'a', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.3[1m]', models: ['glm-5.3[1m]', 'glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-a', legacy: true },
437
- { id: 'claude.zai-p', client: 'claude', provider: 'zai', credentialProfile: 'p', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.3[1m]', models: ['glm-5.3[1m]', 'glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-p', legacy: true },
485
+ { id: 'claude.zai-a', client: 'claude', provider: 'zai', credentialProfile: 'a', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.3[1m]', models: ['glm-5.3[1m]', 'glm-5.2[1m]', 'glm-5.3-flash'], legacySecrets: true, legacyProvider: 'zai-a', legacy: true },
486
+ { id: 'claude.zai-p', client: 'claude', provider: 'zai', credentialProfile: 'p', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.3[1m]', models: ['glm-5.3[1m]', 'glm-5.2[1m]', 'glm-5.3-flash'], legacySecrets: true, legacyProvider: 'zai-p', legacy: true },
438
487
  ]);
439
488
 
440
489
  function profileFor(client, provider, credentialProfile) {
@@ -1510,6 +1559,8 @@ function customCatalogFor(spec, model, declaredModels, home) {
1510
1559
  : [{ effort: 'low', description: 'Fast responses with lighter reasoning' },
1511
1560
  { effort: 'medium', description: 'Balanced reasoning depth' },
1512
1561
  { effort: 'high', description: 'Greater reasoning depth for complex problems' }],
1562
+ input_modalities: m.input || ['text'],
1563
+ supports_parallel_tool_calls: m.supportsParallelToolCalls === true,
1513
1564
  shell_type: 'default',
1514
1565
  visibility: 'list',
1515
1566
  supported_in_api: true,
@@ -1524,12 +1575,10 @@ function customCatalogFor(spec, model, declaredModels, home) {
1524
1575
  apply_patch_tool_type: null,
1525
1576
  web_search_tool_type: 'text',
1526
1577
  truncation_policy: { mode: 'tokens', limit: m.maxTokens || m.contextWindow || 128000 },
1527
- supports_parallel_tool_calls: false,
1528
1578
  supports_image_detail_original: false,
1529
1579
  context_window: m.contextWindow || 128000,
1530
1580
  effective_context_window_percent: 95,
1531
1581
  experimental_supported_tools: [],
1532
- input_modalities: ['text'],
1533
1582
  supports_search_tool: false,
1534
1583
  };
1535
1584
  }),
@@ -1543,8 +1592,12 @@ function customCatalogFor(spec, model, declaredModels, home) {
1543
1592
  else throw e;
1544
1593
  }
1545
1594
  fs.chmodSync(dir, 0o700);
1546
- const target = path.join(dir, `${spec.providerId}.json`);
1547
- const tmp = path.join(dir, `.${spec.providerId}.${crypto.randomBytes(6).toString('hex')}.tmp`);
1595
+ // Nome generico per engine gestito: i custom hanno providerId proprio, gli
1596
+ // altri usano client.provider cosi' QUALSIASI engine che dichiara modelli
1597
+ // ottiene il suo catalogo senza casi speciali per provider.
1598
+ const catalogId = spec.providerId || `${spec.client}.${spec.provider}`;
1599
+ const target = path.join(dir, `${catalogId}.json`);
1600
+ const tmp = path.join(dir, `.${catalogId}.${crypto.randomBytes(6).toString('hex')}.tmp`);
1548
1601
  try {
1549
1602
  fs.writeFileSync(tmp, JSON.stringify(cat), { mode: 0o600 });
1550
1603
  fs.chmodSync(tmp, 0o600);
@@ -1734,8 +1787,26 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1734
1787
  env.OPENAI_API_KEY = cred.value;
1735
1788
  args.push(...codexProviderArgs('ollama_cloud', 'Ollama Cloud', profile.endpoint, 'OPENAI_API_KEY'));
1736
1789
  args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${ollamaContextFor(model) ?? 200000}`);
1737
- const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
1738
- if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
1790
+ // Catalogo generato dagli id DICHIARATI DELL'ENGINE: senza entry il
1791
+ // client cade sul descrittore fallback (272000 fisso, parallel tools
1792
+ // false, nessun reasoning, nessuna modalita' visiva — guasto misurato
1793
+ // su glm-5.3-flash, controllo 2026-08-27). Il file
1794
+ // utente resta come fallback se la generazione non e' possibile.
1795
+ let catalogMeta = null;
1796
+ try {
1797
+ catalogMeta = customCatalogFor(spec, model, ollamaCloudCatalogModels(), home);
1798
+ } catch (_) {
1799
+ // Generazione impossibile (es. path occupato da una directory):
1800
+ // NON deve far fallire il launch — si ricade sul file utente sotto.
1801
+ catalogMeta = null;
1802
+ }
1803
+ if (catalogMeta) {
1804
+ args.push('-c', `model_catalog_json=${JSON.stringify(catalogMeta.catalogPath)}`);
1805
+ if (catalogMeta.contextWindow) args.push('-c', `model_context_window=${catalogMeta.contextWindow}`);
1806
+ } else {
1807
+ const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
1808
+ if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
1809
+ }
1739
1810
  } else if (spec.provider === 'openrouter') {
1740
1811
  env.OPENROUTER_API_KEY = cred.value;
1741
1812
  const authHelper = path.join(__dirname, 'openrouter-auth-helper.js');
@@ -1950,7 +2021,7 @@ function publicCatalog() {
1950
2021
 
1951
2022
  module.exports = {
1952
2023
  knownMcpServerNames,
1953
- CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
2024
+ CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, OLLAMA_MODEL_CAPABILITIES, ollamaCloudCatalogModels, ALIBABA_TOKEN_PLAN_MODELS,
1954
2025
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
1955
2026
  OPENCODE_GO_MESSAGES_MODELS, OPENCODE_GO_RESPONSES_MODELS, OPENCODE_GO_CHAT_MODELS, OPENCODE_GO_LIMITS,
1956
2027
  OPENCODE_GO_ANTHROPIC_ROOT, OPENCODE_GO_API_BASE,