@mmmbuto/nexuscrew 0.7.7 → 0.8.0
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.
- package/README.md +156 -20
- package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
- package/frontend/dist/assets/index-DQrDBogT.js +83 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +415 -31
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +16 -3
- package/lib/cli/init.js +39 -6
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -3
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +117 -0
- package/lib/files/routes.js +55 -1
- package/lib/files/store.js +16 -1
- package/lib/fleet/builtin.js +141 -24
- package/lib/fleet/definitions.js +26 -0
- package/lib/fleet/managed.js +211 -0
- package/lib/fleet/routes.js +5 -4
- package/lib/mcp/server.js +362 -0
- package/lib/nodes/commands.js +396 -0
- package/lib/nodes/store.js +358 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +300 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/node-proxy.js +292 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +206 -12
- package/lib/settings/routes.js +473 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +7 -1
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
- package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
package/lib/files/routes.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const express = require('express');
|
|
2
6
|
const { Router } = require('express');
|
|
3
7
|
const multer = require('multer');
|
|
4
8
|
const store = require('./store.js');
|
|
5
9
|
|
|
6
10
|
// Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
|
|
7
|
-
|
|
11
|
+
// notifier (opzionale, MCP bridge): emette la notify di consegna file outbox.
|
|
12
|
+
function filesRoutes({ cfg, sessionExists, paste, notifier }) {
|
|
8
13
|
const router = Router();
|
|
9
14
|
const upload = multer({
|
|
10
15
|
storage: multer.memoryStorage(),
|
|
@@ -31,6 +36,55 @@ function filesRoutes({ cfg, sessionExists, paste }) {
|
|
|
31
36
|
});
|
|
32
37
|
});
|
|
33
38
|
|
|
39
|
+
// Consegna deliverable dal MCP bridge (nc_send_file): copia un file locale
|
|
40
|
+
// nell'outbox della sessione mittente. Path sorgente fail-closed: stringa
|
|
41
|
+
// assoluta, realpath ESISTENTE sotto HOME (i symlink si risolvono PRIMA del
|
|
42
|
+
// check: un link che esce da HOME viene rifiutato), file regolare.
|
|
43
|
+
// F3 (audit): gated READONLY — la copia e' una scrittura su disco; il gate
|
|
44
|
+
// sta PRIMA di ogni altro check (nessun probe di sessione/path in READONLY).
|
|
45
|
+
const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
46
|
+
router.post('/outbox', express.json({ limit: '8kb' }), (req, res) => {
|
|
47
|
+
try {
|
|
48
|
+
if (bridgeReadonly()) {
|
|
49
|
+
return res.status(403).json({ error: 'READONLY: consegna file bloccata' });
|
|
50
|
+
}
|
|
51
|
+
const b = req.body || {};
|
|
52
|
+
const session = String(b.session || '');
|
|
53
|
+
if (!store.isValidSession(session) || !sessionExists(session)) {
|
|
54
|
+
return res.status(404).json({ error: 'sessione tmux inesistente' });
|
|
55
|
+
}
|
|
56
|
+
if (typeof b.path !== 'string' || !path.isAbsolute(b.path)) {
|
|
57
|
+
return res.status(400).json({ error: 'path deve essere assoluto' });
|
|
58
|
+
}
|
|
59
|
+
if (b.caption !== undefined && (typeof b.caption !== 'string' || b.caption.length > 500)) {
|
|
60
|
+
return res.status(400).json({ error: 'caption deve essere una stringa (max 500)' });
|
|
61
|
+
}
|
|
62
|
+
const home = cfg.home || os.homedir();
|
|
63
|
+
let src;
|
|
64
|
+
try { src = fs.realpathSync(b.path); } catch (_) {
|
|
65
|
+
return res.status(404).json({ error: 'file sorgente inesistente' });
|
|
66
|
+
}
|
|
67
|
+
const homeReal = fs.realpathSync(home);
|
|
68
|
+
if (src !== homeReal && !src.startsWith(homeReal + path.sep)) {
|
|
69
|
+
return res.status(400).json({ error: 'path fuori da HOME' });
|
|
70
|
+
}
|
|
71
|
+
if (!fs.statSync(src).isFile()) {
|
|
72
|
+
return res.status(400).json({ error: 'path non e\' un file regolare' });
|
|
73
|
+
}
|
|
74
|
+
const saved = store.saveDeliverable(cfg.filesRoot, session, src);
|
|
75
|
+
if (!saved) return res.status(400).json({ error: 'sessione invalida' });
|
|
76
|
+
// Notify best-effort (il badge outbox lo genera comunque il watcher).
|
|
77
|
+
if (notifier) {
|
|
78
|
+
notifier.emit({
|
|
79
|
+
title: `file da ${session}`,
|
|
80
|
+
body: b.caption ? `${saved.name} — ${b.caption}` : saved.name,
|
|
81
|
+
session,
|
|
82
|
+
}).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
res.json({ name: saved.name, box: 'outbox', size: saved.size });
|
|
85
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
86
|
+
});
|
|
87
|
+
|
|
34
88
|
router.get('/', (req, res) => {
|
|
35
89
|
const session = String(req.query.session || '');
|
|
36
90
|
if (!store.isValidSession(session)) return res.status(400).json({ error: 'sessione invalida' });
|
package/lib/files/store.js
CHANGED
|
@@ -77,6 +77,21 @@ function saveUpload(root, session, buffer, origName, now = new Date()) {
|
|
|
77
77
|
return { name, path: full, size: buffer.length };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Consegna MCP bridge: copia un file gia' su disco nell'outbox della sessione
|
|
81
|
+
// (badge/watcher scattano da soli). Naming identico a saveUpload (stamp +
|
|
82
|
+
// sanitize + dedup) e COPYFILE_EXCL: mai overwrite. La validazione del path
|
|
83
|
+
// sorgente (assoluto, sotto HOME, realpath) sta nella route, non qui.
|
|
84
|
+
function saveDeliverable(root, session, srcPath, now = new Date()) {
|
|
85
|
+
const dir = ensureBox(root, session, 'outbox');
|
|
86
|
+
if (!dir) return null;
|
|
87
|
+
const base = `${stamp(now)}_${sanitizeName(path.basename(srcPath))}`;
|
|
88
|
+
let name = base;
|
|
89
|
+
for (let i = 1; fs.existsSync(path.join(dir, name)); i += 1) name = `${i}-${base}`;
|
|
90
|
+
const full = path.join(dir, name);
|
|
91
|
+
fs.copyFileSync(srcPath, full, fs.constants.COPYFILE_EXCL);
|
|
92
|
+
return { name, path: full, size: fs.statSync(full).size };
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
function listBox(root, session, box) {
|
|
81
96
|
const dir = boxDir(root, session, box);
|
|
82
97
|
if (!dir) return null;
|
|
@@ -123,5 +138,5 @@ function removeFile(root, session, box, name) {
|
|
|
123
138
|
|
|
124
139
|
module.exports = {
|
|
125
140
|
isValidSession, sanitizeName, stamp, boxDir, ensureBox,
|
|
126
|
-
saveUpload, listBox, resolveExisting, removeFile, BOXES,
|
|
141
|
+
saveUpload, saveDeliverable, listBox, resolveExisting, removeFile, BOXES,
|
|
127
142
|
};
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -28,6 +28,9 @@ const {
|
|
|
28
28
|
parseDefinitions, validateCommandTrust, resolveCwd,
|
|
29
29
|
loadDefinitions, atomicWrite, CAPS,
|
|
30
30
|
} = require('./definitions.js');
|
|
31
|
+
const {
|
|
32
|
+
CATALOG: MANAGED_CATALOG, describeManaged, resolveManagedEngine, discoverOllamaModels,
|
|
33
|
+
} = require('./managed.js');
|
|
31
34
|
|
|
32
35
|
const STATUS_TTL_MS = 2000;
|
|
33
36
|
|
|
@@ -183,11 +186,34 @@ async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, targ
|
|
|
183
186
|
function draftFrom(defs) {
|
|
184
187
|
return {
|
|
185
188
|
schemaVersion: defs.schemaVersion,
|
|
186
|
-
engines: defs.engines.map((e) => ({
|
|
187
|
-
|
|
189
|
+
engines: defs.engines.map((e) => ({
|
|
190
|
+
...e,
|
|
191
|
+
...(e.managed ? { managed: { ...e.managed } } : {}),
|
|
192
|
+
...(e.args ? { args: [...e.args] } : {}),
|
|
193
|
+
...(e.env ? { env: { ...e.env } } : {}),
|
|
194
|
+
...(e.model ? { model: { ...e.model } } : {}),
|
|
195
|
+
})),
|
|
196
|
+
cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
|
|
188
197
|
};
|
|
189
198
|
}
|
|
190
199
|
|
|
200
|
+
// Applica engine + modello come un'unica transizione. Ogni engine ricorda il
|
|
201
|
+
// proprio ultimo modello; passando a un altro engine il modello vecchio non
|
|
202
|
+
// attraversa mai il confine.
|
|
203
|
+
function applyCellEngineModel(target, engineId, model, hasModel) {
|
|
204
|
+
const remembered = { ...(target.models || {}) };
|
|
205
|
+
target.engine = engineId;
|
|
206
|
+
if (hasModel) {
|
|
207
|
+
if (typeof model === 'string' && model) remembered[engineId] = model;
|
|
208
|
+
else delete remembered[engineId];
|
|
209
|
+
}
|
|
210
|
+
const selected = hasModel
|
|
211
|
+
? (typeof model === 'string' ? model : '')
|
|
212
|
+
: (remembered[engineId] || '');
|
|
213
|
+
if (Object.keys(remembered).length) target.models = remembered; else delete target.models;
|
|
214
|
+
if (selected) target.model = selected; else delete target.model;
|
|
215
|
+
}
|
|
216
|
+
|
|
191
217
|
// ---------------------------------------------------------------------------
|
|
192
218
|
// createBuiltinFleet(cfg) — async per parita' di contratto con createFleet.
|
|
193
219
|
// cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
|
|
@@ -240,11 +266,25 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
240
266
|
const alive = sessions.has(c.tmuxSession);
|
|
241
267
|
return {
|
|
242
268
|
cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
|
|
269
|
+
model: c.model || '', models: { ...(c.models || {}) },
|
|
243
270
|
active: alive, boot: c.boot, tmux: alive,
|
|
244
271
|
rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
|
|
245
272
|
};
|
|
246
273
|
});
|
|
247
|
-
const
|
|
274
|
+
const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
|
|
275
|
+
const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
|
|
276
|
+
const engines = cache.defs.engines.map((e) => {
|
|
277
|
+
const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
|
|
278
|
+
return {
|
|
279
|
+
id: e.id, label: e.label, rc: !!e.rc,
|
|
280
|
+
...(managed ? {
|
|
281
|
+
kind: 'managed', client: managed.client, provider: managed.provider,
|
|
282
|
+
model: e.managed.model || managed.defaultModel || '',
|
|
283
|
+
models: managed.provider === 'ollama-cloud' ? ollamaModels : managed.models,
|
|
284
|
+
configured: managed.configured, reason: managed.reason,
|
|
285
|
+
} : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
248
288
|
return {
|
|
249
289
|
available: true,
|
|
250
290
|
provider: 'builtin',
|
|
@@ -269,10 +309,16 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
269
309
|
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
270
310
|
const engine = findEngine(defs, cell.engine);
|
|
271
311
|
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
312
|
+
let launchEngine = engine;
|
|
313
|
+
if (engine.managed) {
|
|
314
|
+
const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
|
|
315
|
+
if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
|
|
316
|
+
launchEngine = resolved.engine;
|
|
317
|
+
}
|
|
272
318
|
|
|
273
319
|
// (2) trust del command PRIMA di lanciare
|
|
274
|
-
const trust = validateCommandTrust(
|
|
275
|
-
if (!trust.ok) throw httpError(400, `command non trusted (${
|
|
320
|
+
const trust = validateCommandTrust(launchEngine.command);
|
|
321
|
+
if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
|
|
276
322
|
// (3) cwd reale sotto la home
|
|
277
323
|
const realCwd = resolveCwd(cell.cwd, home);
|
|
278
324
|
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
|
|
@@ -281,28 +327,28 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
281
327
|
// '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
|
|
282
328
|
// cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
|
|
283
329
|
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
284
|
-
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine, cell });
|
|
330
|
+
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: launchEngine, cell });
|
|
285
331
|
argv.splice(2, 0, '-P', '-F', '#{pane_id}');
|
|
286
332
|
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
287
333
|
if (launch.err) {
|
|
288
334
|
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
289
335
|
const why = /duplicate session/i.test(launch.stderr)
|
|
290
336
|
? 'sessione già in esecuzione'
|
|
291
|
-
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message,
|
|
337
|
+
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
|
|
292
338
|
throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
|
|
293
339
|
}
|
|
294
340
|
|
|
295
341
|
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
296
342
|
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
297
343
|
let prompt = null;
|
|
298
|
-
if (
|
|
344
|
+
if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
|
|
299
345
|
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
300
346
|
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
301
347
|
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
302
348
|
env: minimalEnv(),
|
|
303
349
|
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : 400,
|
|
304
350
|
target,
|
|
305
|
-
engine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
351
|
+
engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
306
352
|
});
|
|
307
353
|
}
|
|
308
354
|
cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
|
|
@@ -332,13 +378,14 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
332
378
|
return up(cellId);
|
|
333
379
|
}
|
|
334
380
|
|
|
335
|
-
async function setEngine(cellId, engId) {
|
|
381
|
+
async function setEngine(cellId, engId, opts = {}) {
|
|
336
382
|
if (readonly()) throw httpError(403, 'READONLY: engine bloccato');
|
|
337
383
|
const defs = reloadDefs();
|
|
338
384
|
if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
339
385
|
if (!findEngine(defs, engId)) throw httpError(400, `engine non valido: ${engId}`);
|
|
340
|
-
await mutate(defs, (d) =>
|
|
341
|
-
|
|
386
|
+
await mutate(defs, (d) => applyCellEngineModel(findCell(d, cellId), engId, opts.model, typeof opts.model === 'string' && opts.model !== ''));
|
|
387
|
+
const saved = reloadDefs(); const target = findCell(saved, cellId);
|
|
388
|
+
return { ok: true, engine: engId, model: target.model || '' };
|
|
342
389
|
}
|
|
343
390
|
|
|
344
391
|
async function setBoot(cellId, enabled) {
|
|
@@ -359,13 +406,31 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
359
406
|
await mutate(reloadDefs(), (d) => { d.engines.push(def); });
|
|
360
407
|
return { ok: true, id: def.id };
|
|
361
408
|
}
|
|
362
|
-
async function editEngine(id, patch) {
|
|
409
|
+
async function editEngine(id, patch, envChanges) {
|
|
363
410
|
if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
|
|
364
411
|
if (!id) throw httpError(400, 'id engine mancante');
|
|
365
412
|
const defs = reloadDefs();
|
|
366
413
|
if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
|
|
367
|
-
|
|
368
|
-
|
|
414
|
+
if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'env'))) {
|
|
415
|
+
throw httpError(400, 'id ed env non sono modificabili tramite patch generica');
|
|
416
|
+
}
|
|
417
|
+
await mutate(defs, (d) => {
|
|
418
|
+
const target = findEngine(d, id);
|
|
419
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
420
|
+
if (value === null) delete target[key]; else target[key] = value;
|
|
421
|
+
}
|
|
422
|
+
if (envChanges !== undefined) {
|
|
423
|
+
if (!envChanges || typeof envChanges !== 'object' || Array.isArray(envChanges)) throw httpError(400, 'envChanges non valido');
|
|
424
|
+
const next = { ...(target.env || {}) };
|
|
425
|
+
for (const key of Array.isArray(envChanges.remove) ? envChanges.remove : []) delete next[key];
|
|
426
|
+
if (envChanges.set && typeof envChanges.set === 'object' && !Array.isArray(envChanges.set)) {
|
|
427
|
+
for (const [key, value] of Object.entries(envChanges.set)) next[key] = value;
|
|
428
|
+
}
|
|
429
|
+
target.env = next;
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
const sessions = await refreshSessions();
|
|
433
|
+
return { ok: true, activeCells: defs.cells.filter((c) => c.engine === id && sessions.has(c.tmuxSession)).map((c) => c.id) };
|
|
369
434
|
}
|
|
370
435
|
async function removeEngine(id) {
|
|
371
436
|
if (readonly()) throw httpError(403, 'READONLY: remove-engine bloccato');
|
|
@@ -373,7 +438,14 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
373
438
|
if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
|
|
374
439
|
const used = defs.cells.filter((c) => c.engine === id);
|
|
375
440
|
if (used.length) throw httpError(400, `engine in uso da ${used.length} cella/e: rimuovi prima la cella`);
|
|
376
|
-
await mutate(defs, (d) => {
|
|
441
|
+
await mutate(defs, (d) => {
|
|
442
|
+
d.engines = d.engines.filter((e) => e.id !== id);
|
|
443
|
+
for (const cell of d.cells) {
|
|
444
|
+
if (!cell.models || !Object.prototype.hasOwnProperty.call(cell.models, id)) continue;
|
|
445
|
+
delete cell.models[id];
|
|
446
|
+
if (!Object.keys(cell.models).length) delete cell.models;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
377
449
|
return { ok: true };
|
|
378
450
|
}
|
|
379
451
|
|
|
@@ -389,17 +461,54 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
389
461
|
if (!id) throw httpError(400, 'id cell mancante');
|
|
390
462
|
const defs = reloadDefs();
|
|
391
463
|
if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
|
|
392
|
-
|
|
393
|
-
|
|
464
|
+
if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'tmuxSession'))) {
|
|
465
|
+
throw httpError(400, 'id e tmuxSession sono immutabili');
|
|
466
|
+
}
|
|
467
|
+
await mutate(defs, (d) => {
|
|
468
|
+
const target = findCell(d, id);
|
|
469
|
+
const hasEngine = typeof patch?.engine === 'string';
|
|
470
|
+
const hasModel = Object.prototype.hasOwnProperty.call(patch || {}, 'model');
|
|
471
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
472
|
+
if (key === 'engine' || key === 'model') continue;
|
|
473
|
+
if (value === null) delete target[key]; else target[key] = value;
|
|
474
|
+
}
|
|
475
|
+
if (hasEngine || hasModel) applyCellEngineModel(target, hasEngine ? patch.engine : target.engine, patch?.model, hasModel);
|
|
476
|
+
});
|
|
477
|
+
const sessions = await refreshSessions();
|
|
478
|
+
return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
|
|
394
479
|
}
|
|
395
|
-
async function removeCell(id) {
|
|
480
|
+
async function removeCell(id, opts = {}) {
|
|
396
481
|
if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
|
|
397
|
-
|
|
398
|
-
|
|
482
|
+
let defs = reloadDefs();
|
|
483
|
+
const cell = findCell(defs, id);
|
|
484
|
+
if (!cell) throw httpError(400, `cell inesistente: ${id}`);
|
|
485
|
+
const sessions = await refreshSessions();
|
|
486
|
+
if (sessions.has(cell.tmuxSession)) {
|
|
487
|
+
if (opts.stop !== true) throw httpError(409, 'cell attiva: conferma stop e rimozione');
|
|
488
|
+
await down(id);
|
|
489
|
+
defs = reloadDefs();
|
|
490
|
+
}
|
|
399
491
|
await mutate(defs, (d) => { d.cells = d.cells.filter((c) => c.id !== id); });
|
|
400
492
|
return { ok: true };
|
|
401
493
|
}
|
|
402
494
|
|
|
495
|
+
// Vista editabile ma secret-safe: gli env values restano write-only.
|
|
496
|
+
function definitions() {
|
|
497
|
+
const defs = reloadDefs();
|
|
498
|
+
return {
|
|
499
|
+
schemaVersion: defs.schemaVersion,
|
|
500
|
+
engines: defs.engines.map((e) => {
|
|
501
|
+
const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
|
|
502
|
+
delete out.env;
|
|
503
|
+
if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
|
|
504
|
+
return out;
|
|
505
|
+
}),
|
|
506
|
+
cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
|
|
507
|
+
managedCatalog: MANAGED_CATALOG.map((p) => ({ ...p })),
|
|
508
|
+
managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
403
512
|
// Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
|
|
404
513
|
// invalido: backup predecessore + throw -> httpError(400) (mai garbage).
|
|
405
514
|
async function mutate(defs, mutator) {
|
|
@@ -421,10 +530,17 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
421
530
|
schemaVersion: 1,
|
|
422
531
|
caps: CAPS,
|
|
423
532
|
engine: {
|
|
533
|
+
kind: { type: 'enum', values: ['managed', 'custom'], default: 'managed' },
|
|
424
534
|
id: { type: 'string', required: true, pattern: '^[a-z0-9._-]{1,32}$', max: 32 },
|
|
425
535
|
label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN, default: '<id>' },
|
|
426
536
|
rc: { type: 'boolean', required: false, default: false },
|
|
427
|
-
|
|
537
|
+
managed: {
|
|
538
|
+
type: 'object', requiredFor: 'managed',
|
|
539
|
+
client: { type: 'enum', values: ['claude', 'codex-vl'] },
|
|
540
|
+
provider: { type: 'enum', values: ['native', 'ollama-cloud', 'zai-a', 'zai-p'] },
|
|
541
|
+
model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
|
|
542
|
+
},
|
|
543
|
+
command: { type: 'string', requiredFor: 'custom', max: CAPS.MAX_COMMAND_LEN, absolute: true },
|
|
428
544
|
args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
|
|
429
545
|
env: {
|
|
430
546
|
type: 'object', required: false, maxKeys: CAPS.MAX_ENV_KEYS,
|
|
@@ -448,13 +564,14 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
448
564
|
engine: { type: 'string', required: true, ref: 'engine.id' },
|
|
449
565
|
boot: { type: 'boolean', required: false, default: false },
|
|
450
566
|
model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
|
|
567
|
+
models: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_MODEL_VAL_LEN },
|
|
451
568
|
prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
|
|
452
569
|
},
|
|
453
570
|
};
|
|
454
571
|
}
|
|
455
572
|
|
|
456
573
|
function capabilities() {
|
|
457
|
-
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema'];
|
|
574
|
+
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema', 'definitions'];
|
|
458
575
|
}
|
|
459
576
|
|
|
460
577
|
return {
|
|
@@ -463,7 +580,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
463
580
|
status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
464
581
|
defineEngine, editEngine, removeEngine,
|
|
465
582
|
defineCell, editCell, removeCell,
|
|
466
|
-
schema, capabilities,
|
|
583
|
+
schema, definitions, capabilities,
|
|
467
584
|
};
|
|
468
585
|
}
|
|
469
586
|
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const crypto = require('node:crypto');
|
|
14
|
+
const { normalizeManagedSpec } = require('./managed.js');
|
|
14
15
|
|
|
15
16
|
// --- Cap + identita' (dichiarati; ragionevoli per un file di flotta locale) ---
|
|
16
17
|
const SCHEMA_VERSION = 1;
|
|
@@ -145,6 +146,17 @@ function parseEngine(e) {
|
|
|
145
146
|
rc = e.rc;
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
// Managed: NexusCrew conosce client/provider e compone internamente il
|
|
150
|
+
// processo. Nessun command/env/argv o segreto vive nella definizione.
|
|
151
|
+
if (e.managed !== undefined) {
|
|
152
|
+
const managed = normalizeManagedSpec(e.managed);
|
|
153
|
+
if (!managed) return null;
|
|
154
|
+
for (const key of ['command', 'args', 'env', 'promptMode', 'promptFlag', 'model']) {
|
|
155
|
+
if (e[key] !== undefined) return null;
|
|
156
|
+
}
|
|
157
|
+
return { id: e.id, label, rc, managed };
|
|
158
|
+
}
|
|
159
|
+
|
|
148
160
|
// command (obbligatorio, stringa non vuota; il trust si verifica a parte)
|
|
149
161
|
if (typeof e.command !== 'string' || !e.command || e.command.length > MAX_COMMAND_LEN) return null;
|
|
150
162
|
|
|
@@ -229,6 +241,19 @@ function parseCell(c, engineIds) {
|
|
|
229
241
|
model = c.model;
|
|
230
242
|
}
|
|
231
243
|
|
|
244
|
+
// Ultimo modello per engine, persistito per cella. Consente di tornare a un
|
|
245
|
+
// provider e ritrovare la scelta precedente senza trascinarla su altri engine.
|
|
246
|
+
let models = {};
|
|
247
|
+
if (c.models !== undefined) {
|
|
248
|
+
if (!c.models || typeof c.models !== 'object' || Array.isArray(c.models)) return null;
|
|
249
|
+
const entries = Object.entries(c.models);
|
|
250
|
+
if (entries.length > MAX_ENGINES) return null;
|
|
251
|
+
for (const [engineId, value] of entries) {
|
|
252
|
+
if (!engineIds.has(engineId) || typeof value !== 'string' || !value || value.length > MAX_MODEL_VAL_LEN) return null;
|
|
253
|
+
models[engineId] = value;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
232
257
|
// prompt (opzionale, cap)
|
|
233
258
|
let prompt;
|
|
234
259
|
if (c.prompt !== undefined) {
|
|
@@ -254,6 +279,7 @@ function parseCell(c, engineIds) {
|
|
|
254
279
|
|
|
255
280
|
const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
|
|
256
281
|
if (model !== undefined) out.model = model;
|
|
282
|
+
if (Object.keys(models).length) out.models = models;
|
|
257
283
|
if (prompt !== undefined) out.prompt = prompt;
|
|
258
284
|
return out;
|
|
259
285
|
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Profili engine gestiti da NexusCrew. La configurazione descrive client e
|
|
3
|
+
// provider; command/argv/env effettivi vengono composti qui, senza shell e senza
|
|
4
|
+
// salvare segreti in fleet.json. I profili custom v1 restano supportati.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const OLLAMA_CLOUD_MODELS = Object.freeze([
|
|
9
|
+
'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
|
|
10
|
+
'qwen3.5:397b', 'deepseek-v4-flash', 'mistral-large-3:675b', 'gemma4:31b',
|
|
11
|
+
]);
|
|
12
|
+
const OLLAMA_CONTEXT = Object.freeze({
|
|
13
|
+
'glm-5.2': 1000000,
|
|
14
|
+
'kimi-k2.7-code': 262144,
|
|
15
|
+
'deepseek-v4-pro': 524288,
|
|
16
|
+
'minimax-m3': 524288,
|
|
17
|
+
'qwen3.5:397b': 262144,
|
|
18
|
+
'deepseek-v4-flash': 1048576,
|
|
19
|
+
'mistral-large-3:675b': 262144,
|
|
20
|
+
'gemma4:31b': 262144,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const CATALOG = Object.freeze([
|
|
24
|
+
{ id: 'claude.native', client: 'claude', provider: 'native', label: 'Claude · Native', auth: 'login', endpoint: 'Anthropic account', rc: true, default: true },
|
|
25
|
+
{ id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: 'Codex-VL · Native', auth: 'login', endpoint: 'OpenAI account', rc: false, default: true },
|
|
26
|
+
{ id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Claude · Ollama Cloud Direct', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, rc: false, default: false },
|
|
27
|
+
{ id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Codex-VL · Ollama Cloud Direct', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, rc: false, default: false },
|
|
28
|
+
{ id: 'claude.zai-a', client: 'claude', provider: 'zai-a', label: 'Claude · Z.AI A', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], rc: false, default: false },
|
|
29
|
+
{ id: 'claude.zai-p', client: 'claude', provider: 'zai-p', label: 'Claude · Z.AI P', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], rc: false, default: false },
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const byPair = (client, provider) => CATALOG.find((p) => p.client === client && p.provider === provider) || null;
|
|
33
|
+
|
|
34
|
+
function normalizeManagedSpec(value) {
|
|
35
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
36
|
+
const keys = Object.keys(value);
|
|
37
|
+
if (keys.some((k) => !['client', 'provider', 'model'].includes(k))) return null;
|
|
38
|
+
const profile = byPair(value.client, value.provider);
|
|
39
|
+
if (!profile) return null;
|
|
40
|
+
const model = value.model === undefined ? (profile.model || '') : value.model;
|
|
41
|
+
if (typeof model !== 'string' || model.length > 128) return null;
|
|
42
|
+
return { client: profile.client, provider: profile.provider, model };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function defaultDefinitions() {
|
|
46
|
+
return {
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
engines: CATALOG.filter((p) => p.default).map((p) => ({
|
|
49
|
+
id: p.id, label: p.label, rc: p.rc,
|
|
50
|
+
managed: { client: p.client, provider: p.provider, model: p.model || '' },
|
|
51
|
+
})),
|
|
52
|
+
cells: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseEnvFile(file) {
|
|
57
|
+
const out = {};
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
const st = fs.lstatSync(file);
|
|
61
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077)) return out;
|
|
62
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
63
|
+
} catch (_) { return out; }
|
|
64
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
65
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
66
|
+
if (!m) continue;
|
|
67
|
+
let value = m[2];
|
|
68
|
+
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) value = value.slice(1, -1);
|
|
69
|
+
out[m[1]] = value;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function binaryCandidates(client, home) {
|
|
75
|
+
const prefix = process.env.PREFIX || '';
|
|
76
|
+
if (client === 'claude') return [path.join(home, '.local', 'bin', 'claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude', prefix && path.join(prefix, 'bin', 'claude')].filter(Boolean);
|
|
77
|
+
return [path.join(home, '.local', 'bin', 'codex-vl'), '/usr/local/bin/codex-vl', '/opt/homebrew/bin/codex-vl', prefix && path.join(prefix, 'bin', 'codex-vl')].filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findBinary(client, home) {
|
|
81
|
+
for (const candidate of binaryCandidates(client, home)) {
|
|
82
|
+
try {
|
|
83
|
+
const real = fs.realpathSync(candidate);
|
|
84
|
+
const st = fs.lstatSync(real);
|
|
85
|
+
if (!st.isFile() || !(st.mode & 0o100) || (st.mode & 0o002)) continue;
|
|
86
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) continue;
|
|
87
|
+
return real;
|
|
88
|
+
} catch (_) { /* next */ }
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function secretsPath(cfg, home) {
|
|
94
|
+
return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS
|
|
95
|
+
|| path.join(home, '.nexuscrew', 'providers.env');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let ollamaCache = { at: 0, models: [] };
|
|
99
|
+
async function discoverOllamaModels(opts = {}) {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
|
|
102
|
+
if (!opts.noCache && ollamaCache.models.length && now - ollamaCache.at < ttl) return [...ollamaCache.models];
|
|
103
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
104
|
+
if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
|
|
105
|
+
try {
|
|
106
|
+
const home = opts.home || require('node:os').homedir();
|
|
107
|
+
const apiKey = opts.apiKey || parseEnvFile(secretsPath(opts, home)).OLLAMA_API_KEY;
|
|
108
|
+
if (!apiKey) throw new Error('OLLAMA_API_KEY mancante');
|
|
109
|
+
const response = await fetchImpl('https://ollama.com/api/tags', {
|
|
110
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
111
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined,
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
114
|
+
const body = await response.json();
|
|
115
|
+
const available = new Set();
|
|
116
|
+
for (const item of Array.isArray(body.models) ? body.models : []) {
|
|
117
|
+
const name = typeof item?.name === 'string' ? item.name : '';
|
|
118
|
+
if (!/^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/.test(name) || name.length > 128) continue;
|
|
119
|
+
available.add(name);
|
|
120
|
+
}
|
|
121
|
+
// La API diretta espone molti modelli, inclusi modelli in deprecazione. La UI
|
|
122
|
+
// mantiene la shortlist TOP curata, ma solo se ancora realmente disponibile.
|
|
123
|
+
const models = OLLAMA_CLOUD_MODELS.filter((name) => available.has(name));
|
|
124
|
+
if (!models.length) throw new Error('nessun modello cloud');
|
|
125
|
+
ollamaCache = { at: now, models };
|
|
126
|
+
return [...models];
|
|
127
|
+
} catch (_) {
|
|
128
|
+
ollamaCache = { at: now, models: [...OLLAMA_CLOUD_MODELS] };
|
|
129
|
+
return [...OLLAMA_CLOUD_MODELS];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function describeManaged(spec, cfg = {}) {
|
|
134
|
+
const normalized = normalizeManagedSpec(spec);
|
|
135
|
+
if (!normalized) return { configured: false, reason: 'profilo managed non valido' };
|
|
136
|
+
const home = cfg.home || require('node:os').homedir();
|
|
137
|
+
const profile = byPair(normalized.client, normalized.provider);
|
|
138
|
+
const binary = findBinary(normalized.client, home);
|
|
139
|
+
const secretFile = secretsPath(cfg, home);
|
|
140
|
+
const secrets = profile.auth === 'login' || profile.auth === 'none' ? {} : parseEnvFile(secretFile);
|
|
141
|
+
const authConfigured = profile.auth === 'login' || profile.auth === 'none' || !!secrets[profile.auth];
|
|
142
|
+
const configured = !!binary && authConfigured;
|
|
143
|
+
return {
|
|
144
|
+
client: profile.client, provider: profile.provider, model: normalized.model,
|
|
145
|
+
endpoint: profile.endpoint, auth: profile.auth, authConfigured, configured,
|
|
146
|
+
models: [...(profile.models || [])], defaultModel: profile.model || '',
|
|
147
|
+
binary: binary || '',
|
|
148
|
+
reason: !binary ? `client ${profile.client} non trovato` : (!authConfigured ? `credenziale ${profile.auth} mancante` : 'ready'),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
153
|
+
const info = describeManaged(engine.managed, cfg);
|
|
154
|
+
if (!info.configured) return { ok: false, reason: info.reason, info };
|
|
155
|
+
const home = cfg.home || require('node:os').homedir();
|
|
156
|
+
const env = {};
|
|
157
|
+
const args = [];
|
|
158
|
+
const model = (cell && cell.model) || info.model;
|
|
159
|
+
if (info.client === 'claude') {
|
|
160
|
+
args.push('--dangerously-skip-permissions');
|
|
161
|
+
if (info.provider === 'native') {
|
|
162
|
+
if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
|
|
163
|
+
} else if (info.provider === 'ollama-cloud') {
|
|
164
|
+
const secrets = parseEnvFile(secretsPath(cfg, home));
|
|
165
|
+
Object.assign(env, {
|
|
166
|
+
ANTHROPIC_BASE_URL: 'https://ollama.com', ANTHROPIC_AUTH_TOKEN: secrets.OLLAMA_API_KEY,
|
|
167
|
+
ANTHROPIC_API_KEY: '',
|
|
168
|
+
ANTHROPIC_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model, API_TIMEOUT_MS: '3000000',
|
|
169
|
+
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(OLLAMA_CONTEXT[model] || 200000),
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
const profile = byPair(info.client, info.provider);
|
|
173
|
+
const secrets = parseEnvFile(secretsPath(cfg, home));
|
|
174
|
+
Object.assign(env, {
|
|
175
|
+
ANTHROPIC_BASE_URL: profile.endpoint, ANTHROPIC_AUTH_TOKEN: secrets[profile.auth],
|
|
176
|
+
ANTHROPIC_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model,
|
|
177
|
+
API_TIMEOUT_MS: '3000000', CLAUDE_CODE_AUTO_COMPACT_WINDOW: '1000000',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (model) args.push('--model', model);
|
|
181
|
+
} else {
|
|
182
|
+
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
183
|
+
if (info.provider === 'ollama-cloud') {
|
|
184
|
+
const secrets = parseEnvFile(secretsPath(cfg, home));
|
|
185
|
+
env.OPENAI_API_KEY = secrets.OLLAMA_API_KEY;
|
|
186
|
+
if (model) args.push('-m', model);
|
|
187
|
+
const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
|
|
188
|
+
args.push(
|
|
189
|
+
'-c', 'model_provider="ollama_cloud"',
|
|
190
|
+
'-c', 'model_providers.ollama_cloud.name="Ollama Cloud"',
|
|
191
|
+
'-c', 'model_providers.ollama_cloud.base_url="https://ollama.com/v1"',
|
|
192
|
+
'-c', 'model_providers.ollama_cloud.wire_api="responses"',
|
|
193
|
+
'-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000',
|
|
194
|
+
'-c', `model_context_window=${OLLAMA_CONTEXT[model] || 200000}`,
|
|
195
|
+
);
|
|
196
|
+
// Codex carica il catalogo solo all'avvio. Il file e' locale e opzionale:
|
|
197
|
+
// sui device che non lo hanno resta valido il fallback context-window sopra.
|
|
198
|
+
if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Entrambi i client accettano il bootstrap come argomento posizionale. Questo
|
|
202
|
+
// evita di digitare nella TUI prima che sia pronta; argv e' diretto, mai shell.
|
|
203
|
+
if (cell && cell.prompt) args.push(cell.prompt);
|
|
204
|
+
return { ok: true, info, engine: { ...engine, command: info.binary, args, env, promptMode: 'managed-argv' } };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, normalizeManagedSpec, defaultDefinitions, describeManaged,
|
|
209
|
+
discoverOllamaModels,
|
|
210
|
+
resolveManagedEngine, parseEnvFile, findBinary,
|
|
211
|
+
};
|