@mmmbuto/nexuscrew 0.8.11 → 0.8.13

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-DoUIJ4GM.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DrrRAIg6.css">
14
+ <script type="module" crossorigin src="/assets/index-DuIR61l-.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-4rNd0SwZ.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.11"}
1
+ {"version":"0.8.13"}
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const { isValidSession } = require('../files/store.js');
5
+ const { submitTextOk } = require('../tmux/actions.js');
6
+
7
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
8
+ const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
9
+ const MESSAGE_ID_RE = /^[a-f0-9-]{16,64}$/;
10
+
11
+ function publicCells(status, instanceId, now = Date.now()) {
12
+ if (!NODE_ID_RE.test(String(instanceId || '')) || !status || status.available !== true
13
+ || !Array.isArray(status.cells)) return [];
14
+ const seen = new Set();
15
+ const cells = [];
16
+ for (const raw of status.cells) {
17
+ if (!raw || !CELL_ID_RE.test(String(raw.cell || ''))
18
+ || !isValidSession(raw.tmuxSession) || seen.has(raw.cell)) continue;
19
+ seen.add(raw.cell);
20
+ // Provider external legacy may not expose `tmux`; an explicit false still
21
+ // wins, while the historical `active:true` contract remains compatible.
22
+ const active = raw.active === true && raw.tmux !== false;
23
+ cells.push({
24
+ id: `${instanceId}:${raw.cell}`,
25
+ instanceId,
26
+ cell: raw.cell,
27
+ tmuxSession: raw.tmuxSession,
28
+ engine: typeof raw.engine === 'string' ? raw.engine : '',
29
+ model: typeof raw.model === 'string' ? raw.model : '',
30
+ active,
31
+ canReceive: active,
32
+ lastSeen: active ? now : null,
33
+ });
34
+ }
35
+ return cells;
36
+ }
37
+
38
+ function parseVisited(req) {
39
+ const raw = String(req.headers['x-nexuscrew-visited'] || '');
40
+ if (!raw) return [];
41
+ const ids = raw.split(',');
42
+ if (!ids.length || ids.length > 5 || ids.some((id) => !NODE_ID_RE.test(id))
43
+ || new Set(ids).size !== ids.length) return null;
44
+ return ids;
45
+ }
46
+
47
+ function validIdentity(value) {
48
+ return value && typeof value === 'object' && !Array.isArray(value)
49
+ && NODE_ID_RE.test(String(value.instanceId || ''))
50
+ && CELL_ID_RE.test(String(value.cell || ''))
51
+ && isValidSession(value.tmuxSession);
52
+ }
53
+
54
+ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now = () => Date.now() }) {
55
+ const r = express.Router();
56
+
57
+ async function status() {
58
+ const fleet = await fleetP;
59
+ if (!fleet || fleet.available !== true || typeof fleet.status !== 'function') {
60
+ return { available: false, cells: [] };
61
+ }
62
+ return fleet.status();
63
+ }
64
+
65
+ r.get('/', async (_req, res) => {
66
+ try {
67
+ const nodeId = instanceId();
68
+ const st = await status();
69
+ res.json({
70
+ instanceId: NODE_ID_RE.test(String(nodeId || '')) ? nodeId : null,
71
+ available: st.available === true,
72
+ at: now(),
73
+ cells: publicCells(st, nodeId, now()),
74
+ });
75
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
76
+ });
77
+
78
+ r.post('/send', express.json({ limit: '16kb' }), async (req, res) => {
79
+ if (readonly()) return res.status(403).json({ error: 'READONLY: invio cella bloccato' });
80
+ const body = req.body || {};
81
+ const keys = Object.keys(body);
82
+ if (keys.some((key) => !['id', 'from', 'to', 'message'].includes(key))
83
+ || !MESSAGE_ID_RE.test(String(body.id || ''))
84
+ || !validIdentity(body.from) || !validIdentity(body.to)
85
+ || !submitTextOk(body.message)) {
86
+ return res.status(400).json({ error: 'messaggio cella non valido' });
87
+ }
88
+ const localId = instanceId();
89
+ if (!NODE_ID_RE.test(String(localId || '')) || body.to.instanceId !== localId) {
90
+ return res.status(409).json({ error: 'destinazione non appartiene a questo nodo' });
91
+ }
92
+ const visited = parseVisited(req);
93
+ if (visited === null || (visited.length && (visited.at(-1) !== localId
94
+ || body.from.instanceId !== visited[0]))) {
95
+ return res.status(403).json({ error: 'identita mittente non verificata' });
96
+ }
97
+ if (!visited.length && body.from.instanceId !== localId) {
98
+ return res.status(403).json({ error: 'mittente remoto senza route autenticata' });
99
+ }
100
+ try {
101
+ const cells = publicCells(await status(), localId, now());
102
+ const target = cells.find((cell) => cell.cell === body.to.cell
103
+ && cell.tmuxSession === body.to.tmuxSession);
104
+ if (!target) return res.status(404).json({ error: 'cella destinataria sconosciuta' });
105
+ if (!target.canReceive) return res.status(409).json({ error: 'cella destinataria non attiva' });
106
+ const label = `${body.from.cell}@${body.from.instanceId.slice(0, 8)}`;
107
+ // End on printable text even when the source message ends in a newline:
108
+ // Pi may auto-submit a bracketed paste that ends with LF. NexusCrew owns
109
+ // the single explicit Enter used by the transport.
110
+ const envelope = `[NexusCrew message ${body.id} from ${label}]\n${body.message}\n[End NexusCrew message]`;
111
+ const outcome = await submit(target.tmuxSession, envelope, { engine: target.engine });
112
+ if (!outcome || outcome.submitted !== true) {
113
+ return res.status(409).json({ error: outcome?.reason || 'consegna non riuscita' });
114
+ }
115
+ const at = now();
116
+ return res.json({
117
+ id: body.id,
118
+ status: 'submitted',
119
+ at,
120
+ to: { instanceId: localId, cell: target.cell, tmuxSession: target.tmuxSession },
121
+ note: 'submitted conferma solo paste+Enter nel TUI, non elaborazione o completamento',
122
+ });
123
+ } catch (e) { return res.status(500).json({ error: String(e.message || e) }); }
124
+ });
125
+
126
+ r.use((err, _req, res, _next) => {
127
+ if (err && (err.type === 'entity.too.large' || err.status === 413)) {
128
+ return res.status(413).json({ error: 'body troppo grande' });
129
+ }
130
+ if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
131
+ return res.status(err.status || 400).json({ error: String(err.message || err) });
132
+ });
133
+
134
+ return r;
135
+ }
136
+
137
+ module.exports = { cellsRoutes, publicCells, parseVisited, validIdentity };
package/lib/cli/doctor.js CHANGED
@@ -67,7 +67,12 @@ function checkBoot(platform, home, execImpl) {
67
67
  if (platform === 'termux') {
68
68
  const p = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
69
69
  const ok = fs.existsSync(p);
70
- return { name: 'boot script', ok, warn: !ok, detail: ok ? p : 'nessun Termux:boot script' };
70
+ return {
71
+ name: 'boot script', ok, warn: ok,
72
+ detail: ok
73
+ ? `${p} · app Termux:Boot non verificabile da CLI: installala e avviala una volta`
74
+ : 'nessun Termux:boot script',
75
+ };
71
76
  }
72
77
  if (platform === 'linux') {
73
78
  try {
@@ -84,6 +89,24 @@ function checkBoot(platform, home, execImpl) {
84
89
  return { name: 'boot (launchd RunAtLoad)', ok: true, warn: !ok, detail: ok ? 'plist installato' : 'plist non installato' };
85
90
  }
86
91
 
92
+ function checkUserLinger(platform, execImpl, uidVal) {
93
+ if (platform !== 'linux') {
94
+ return { name: 'user linger', ok: true, detail: `${platform}: non applicabile` };
95
+ }
96
+ try {
97
+ const value = String(execImpl('loginctl', [
98
+ 'show-user', String(uidVal), '--property=Linger', '--value',
99
+ ], { encoding: 'utf8' }) || '').trim().toLowerCase();
100
+ const enabled = value === 'yes';
101
+ return {
102
+ name: 'user linger', ok: true, warn: !enabled,
103
+ detail: enabled ? 'enabled · il servizio user puo partire senza login' : 'disabled · abilita con loginctl enable-linger per il boot senza login',
104
+ };
105
+ } catch (_) {
106
+ return { name: 'user linger', ok: true, warn: true, detail: 'non verificabile; il boot senza login potrebbe non partire' };
107
+ }
108
+ }
109
+
87
110
  function checkTmuxSurvival(platform, execImpl) {
88
111
  if (platform !== 'linux') {
89
112
  return { name: 'tmux survival on service restart', ok: true, detail: `${platform}: systemd cgroup non applicabile` };
@@ -159,6 +182,7 @@ function doctor(opts = {}) {
159
182
  checkPty(ptyLoad),
160
183
  checkService(platform, home, execImpl, uidVal, opts.installPath),
161
184
  checkBoot(platform, home, execImpl),
185
+ checkUserLinger(platform, execImpl, uidVal),
162
186
  checkTmuxSurvival(platform, execImpl),
163
187
  checkTokenPerms(tokenPath),
164
188
  checkSshClient(existsImpl),
@@ -178,6 +202,6 @@ function doctor(opts = {}) {
178
202
  module.exports = {
179
203
  doctor, nodeMajor,
180
204
  checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
181
- checkTmuxSurvival,
205
+ checkTmuxSurvival, checkUserLinger,
182
206
  checkSshClient, checkAutossh, checkSshPermitlisten,
183
207
  };
@@ -59,13 +59,16 @@ function generateService(platform, ctx) {
59
59
  function generateLinux(ctx) {
60
60
  assertSystemdSafe('repoRoot', ctx.repoRoot); // [M3] reject char non gestibili
61
61
  assertSystemdSafe('nodeBin', ctx.nodeBin);
62
- const repo = escapeSystemdPath(ctx.repoRoot);
62
+ const workDir = ctx.runtimeDir || path.join(ctx.home, '.nexuscrew');
63
+ assertSystemdSafe('runtimeDir', workDir);
64
+ const runtime = escapeSystemdPath(workDir);
63
65
  const node = escapeSystemdExec(ctx.nodeBin);
64
66
  const repoBin = escapeSystemdExec(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
65
67
  const nodeDir = escapeSystemdPath(path.dirname(ctx.nodeBin));
66
68
  return `# NexusCrew service (systemd --user, loopback, solo tunnel SSH/VPN)
67
69
  [Unit]
68
70
  Description=NexusCrew - browser tmux client (loopback, solo tunnel SSH/VPN)
71
+ Wants=network-online.target
69
72
  After=network-online.target
70
73
 
71
74
  [Service]
@@ -73,7 +76,7 @@ Type=simple
73
76
  # The HTTP service may be the process that creates the shared tmux server.
74
77
  # Restart only NexusCrew itself; tmux sessions are independent user workloads.
75
78
  KillMode=process
76
- WorkingDirectory=${repo}
79
+ WorkingDirectory=${runtime}
77
80
  Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
78
81
  ExecStart=${node} ${repoBin} serve
79
82
  Restart=on-failure
@@ -87,7 +90,7 @@ WantedBy=default.target
87
90
  function generateMac(ctx) {
88
91
  const nodeXml = escapeXml(ctx.nodeBin);
89
92
  const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
90
- const repoXml = escapeXml(ctx.repoRoot);
93
+ const runtimeXml = escapeXml(ctx.runtimeDir || path.join(ctx.home, '.nexuscrew'));
91
94
  const homeXml = escapeXml(ctx.home);
92
95
  const launchPath = [...new Set([
93
96
  path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
@@ -106,11 +109,15 @@ function generateMac(ctx) {
106
109
  <string>serve</string>
107
110
  </array>
108
111
  <key>WorkingDirectory</key>
109
- <string>${repoXml}</string>
112
+ <string>${runtimeXml}</string>
110
113
  <key>EnvironmentVariables</key>
111
114
  <dict>
112
115
  <key>PATH</key>
113
116
  <string>${launchPathXml}</string>
117
+ <key>LANG</key>
118
+ <string>en_US.UTF-8</string>
119
+ <key>LC_CTYPE</key>
120
+ <string>UTF-8</string>
114
121
  </dict>
115
122
  <key>RunAtLoad</key>
116
123
  <true/>
@@ -131,14 +138,15 @@ function generateMac(ctx) {
131
138
  function generateTermux(ctx) {
132
139
  const nodeQ = shellQuote(ctx.nodeBin);
133
140
  const repoBinQ = shellQuote(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
134
- const repoQ = shellQuote(ctx.repoRoot);
135
141
  return `#!/data/data/com.termux/files/usr/bin/sh
136
142
  # NexusCrew boot (Termux) - loopback, localhost del telefono
137
143
  export PATH=/data/data/com.termux/files/usr/bin:$PATH
138
144
  export HOME=/data/data/com.termux/files/home
139
- cd -- ${repoQ}
145
+ export LANG=\${LANG:-en_US.UTF-8}
146
+ export LC_CTYPE=\${LC_CTYPE:-en_US.UTF-8}
140
147
  termux-wake-lock 2>/dev/null || true
141
148
  mkdir -p "$HOME/.nexuscrew"
149
+ cd -- "$HOME/.nexuscrew"
142
150
  exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
143
151
  `;
144
152
  }
@@ -228,6 +236,11 @@ function installService(platform, content, ctx, { dryRun = false, execImpl = exe
228
236
  return { target, mode, written: false, failures: [], note: 'dry-run: nessuna scrittura' };
229
237
  }
230
238
 
239
+ const runtimeDir = ctx.runtimeDir || path.join(home, '.nexuscrew');
240
+ // A real install always has an existing home. Test/dry relocation may pass a
241
+ // synthetic home together with a custom installPath; do not escape that
242
+ // relocation merely to create a WorkingDirectory that will never be used.
243
+ if (fs.existsSync(home) || ctx.runtimeDir) fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
231
244
  fs.mkdirSync(path.dirname(target), { recursive: true });
232
245
  // temp file stessa dir (per atomic rename su stesso filesystem)
233
246
  const tmp = target + '.tmp.' + process.pid;
package/lib/config.js CHANGED
@@ -30,6 +30,9 @@ function baseDefaults() {
30
30
  fleetEnabled: true,
31
31
  fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
32
32
  providerSecretsPath: path.join(os.homedir(), '.nexuscrew', 'providers.env'),
33
+ // Existing user-owned shell exports. NexusCrew parses simple assignments
34
+ // as data; it never executes/sources this file and never copies values.
35
+ providerShellPath: path.join(os.homedir(), '.config', 'ai-shell', 'providers.zsh'),
33
36
  // Installazioni npm globali controllano periodicamente il dist-tag latest.
34
37
  // Il manager aggiorna solo verso una semver superiore: mai downgrade.
35
38
  autoUpdate: true,
@@ -66,6 +69,7 @@ function envOverrides() {
66
69
  if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
67
70
  if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
68
71
  if (process.env.NEXUSCREW_PROVIDER_SECRETS) e.providerSecretsPath = process.env.NEXUSCREW_PROVIDER_SECRETS;
72
+ if (process.env.NEXUSCREW_PROVIDER_SHELL) e.providerShellPath = process.env.NEXUSCREW_PROVIDER_SHELL;
69
73
  if (process.env.NEXUSCREW_AUTO_UPDATE !== undefined) {
70
74
  e.autoUpdate = !['', '0', 'false', 'no', 'off'].includes(String(process.env.NEXUSCREW_AUTO_UPDATE).toLowerCase());
71
75
  }
@@ -31,6 +31,7 @@ const {
31
31
  const {
32
32
  publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
33
33
  } = require('./managed.js');
34
+ const { MINIMAL_ENV_KEYS, minimalRuntimeEnv } = require('../runtime/env.js');
34
35
 
35
36
  const STATUS_TTL_MS = 2000;
36
37
 
@@ -40,20 +41,8 @@ const STATUS_TTL_MS = 2000;
40
41
  // Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
41
42
  // ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
42
43
  // iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
43
- const MINIMAL_ENV_KEYS = [
44
- 'PATH', 'HOME', 'SHELL', 'TERM', 'LANG', 'LANGUAGE',
45
- 'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME',
46
- 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS',
47
- ];
48
44
  function minimalEnv() {
49
- const env = {};
50
- for (const k of MINIMAL_ENV_KEYS) {
51
- if (process.env[k] !== undefined && process.env[k] !== '') env[k] = process.env[k];
52
- }
53
- if (!env.PATH) env.PATH = '/usr/local/bin:/usr/bin:/bin';
54
- if (!env.HOME) env.HOME = os.homedir();
55
- if (!env.TERM) env.TERM = 'xterm-256color';
56
- return env;
45
+ return minimalRuntimeEnv(process.env, { home: os.homedir() });
57
46
  }
58
47
 
59
48
  function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
@@ -88,6 +77,32 @@ function redactSecrets(text, engine, cell) {
88
77
  return out;
89
78
  }
90
79
 
80
+ const MAX_EARLY_DIAGNOSTIC = 1200;
81
+
82
+ function sanitizeEarlyDiagnostic(text, engine, cell, home) {
83
+ let out = redactSecrets(String(text || ''), engine, cell);
84
+ // ANSI CSI/OSC e byte di controllo non devono arrivare nell'errore JSON/UI.
85
+ out = out.replace(/\x1b\][^\x07]*(?:\x07|$)/g, '')
86
+ .replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '');
87
+ let clean = '';
88
+ for (let i = 0; i < out.length; i += 1) {
89
+ const code = out.charCodeAt(i);
90
+ if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) clean += out[i];
91
+ }
92
+ out = clean;
93
+ if (typeof home === 'string' && home) out = out.split(home).join('~');
94
+ out = out
95
+ .replace(/\bBearer\s+\S+/gi, `Bearer ${REDACTED}`)
96
+ .replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|AUTH)[A-Z0-9_]*)(\s*[:=]\s*)\S+/g,
97
+ (_m, key, sep) => `${key}${sep}${REDACTED}`)
98
+ .replace(/\b(?:sk|fw|fpk|hf|zai)-[A-Za-z0-9._-]{8,}\b/gi, REDACTED);
99
+ const lines = out.split(/\r?\n/).map((line) => line.trimEnd())
100
+ .filter((line) => line.trim() && !/^Pane is dead \(status /i.test(line.trim()));
101
+ out = lines.join('\n').trim();
102
+ if (out.length > MAX_EARLY_DIAGNOSTIC) out = `…${out.slice(-(MAX_EARLY_DIAGNOSTIC - 1))}`;
103
+ return out;
104
+ }
105
+
91
106
  // Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
92
107
  // cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
93
108
  function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
@@ -152,6 +167,25 @@ async function waitAlive(tmuxBin, session, { env, readyMs }) {
152
167
  }
153
168
  }
154
169
 
170
+ async function waitStablePane(tmuxBin, target, { env, readyMs }) {
171
+ const deadline = Date.now() + Math.max(0, readyMs | 0);
172
+ for (;;) {
173
+ const state = await tmuxExec(tmuxBin,
174
+ ['display-message', '-p', '-t', target, '#{pane_dead}\t#{pane_dead_status}\t#{pane_id}'],
175
+ { env, timeoutMs: 2000 });
176
+ if (state.err) return { alive: false, status: null, target: null };
177
+ const [dead, rawStatus, paneId] = state.stdout.trim().split('\t');
178
+ if (!/^%[0-9]+$/.test(paneId || '')) return { alive: false, status: null, target: null };
179
+ if (dead === '1') {
180
+ const status = /^-?[0-9]+$/.test(rawStatus || '') ? Number(rawStatus) : null;
181
+ return { alive: false, status, target: paneId };
182
+ }
183
+ if (dead !== '0') return { alive: false, status: null, target: null };
184
+ if (Date.now() >= deadline) return { alive: true, status: null, target: paneId };
185
+ await sleep(60);
186
+ }
187
+ }
188
+
155
189
  // Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
156
190
  // load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
157
191
  // poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
@@ -257,12 +291,14 @@ async function createBuiltinFleet(cfg = {}) {
257
291
  function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
258
292
 
259
293
  async function refreshSessions() {
260
- const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { env: minimalEnv() });
294
+ const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}\t#{session_windows}'], { env: minimalEnv() });
261
295
  if (r.err) return new Set(); // nessun server / nessuna sessione
262
296
  const set = new Set();
263
297
  for (const line of r.stdout.split('\n')) {
264
- const n = line.trim();
265
- if (n) set.add(n);
298
+ const [rawName, rawWindows] = line.split('\t');
299
+ const n = String(rawName || '').trim();
300
+ const windows = rawWindows === undefined ? 1 : Number(rawWindows);
301
+ if (n && Number.isFinite(windows) && windows > 0) set.add(n);
266
302
  }
267
303
  return set;
268
304
  }
@@ -362,6 +398,10 @@ async function createBuiltinFleet(cfg = {}) {
362
398
  // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
363
399
  const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: launchEngine, cell });
364
400
  argv.splice(2, 0, '-P', '-F', '#{pane_id}');
401
+ // Mantieni il pane morto solo durante la finestra di readiness: permette di
402
+ // catturare un errore reale del client senza lasciare una sessione fantasma.
403
+ // Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
404
+ argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
365
405
  const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
366
406
  if (launch.err) {
367
407
  // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
@@ -376,17 +416,34 @@ async function createBuiltinFleet(cfg = {}) {
376
416
  // this readiness gate the PWA reported success and then showed nothing.
377
417
  // Always verify liveness, including cells without a system prompt.
378
418
  const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
379
- const alive = await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs });
380
- if (!alive) {
419
+ const paneId = launch.stdout.trim().split('\n')[0] || '';
420
+ const readiness = paneId.startsWith('%')
421
+ ? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
422
+ : { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
423
+ if (!readiness.alive) {
424
+ let diagnostic = '';
425
+ if (readiness.target) {
426
+ const captured = await tmuxExec(tmuxBin,
427
+ ['capture-pane', '-p', '-S', '-80', '-t', readiness.target], { env: minimalEnv(), timeoutMs: 2000 });
428
+ if (!captured.err) diagnostic = sanitizeEarlyDiagnostic(captured.stdout, launchEngine, cell, home);
429
+ }
430
+ // remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
431
+ // nella Fleet o nella lista tmux dopo aver raccolto l'errore.
432
+ await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
381
433
  cache = { ...cache, at: 0 };
382
- throw httpError(500, `client ${launchEngine.command} terminato subito: verifica login, provider, modello e argomenti dell'engine`);
434
+ const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
435
+ const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
436
+ throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`);
437
+ }
438
+ if (readiness.target) {
439
+ await tmuxExec(tmuxBin,
440
+ ['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
383
441
  }
384
442
 
385
443
  // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
386
444
  // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
387
445
  let prompt = null;
388
446
  if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
389
- const paneId = launch.stdout.trim().split('\n')[0] || '';
390
447
  const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
391
448
  prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
392
449
  env: minimalEnv(),
@@ -605,6 +662,68 @@ async function createBuiltinFleet(cfg = {}) {
605
662
  .filter((cell) => cell && sessions.has(cell.tmuxSession)).map((cell) => cell.id),
606
663
  };
607
664
  }
665
+
666
+ // Ripristino engine separato e atomico. Il formato portatile non accetta mai
667
+ // `env` values: per un engine custom nuovo parte da env vuoto; in overwrite
668
+ // conserva gli eventuali valori write-only già configurati sul target.
669
+ async function restoreEngines(engines, opts = {}) {
670
+ if (readonly()) throw httpError(403, 'READONLY: restore-engines bloccato');
671
+ if (!Array.isArray(engines) || engines.length < 1 || engines.length > 24) {
672
+ throw httpError(400, 'engines deve contenere 1..24 definizioni');
673
+ }
674
+ const allowed = new Set(['id', 'label', 'rc', 'managed', 'command', 'args', 'envKeys', 'model', 'promptMode', 'promptFlag']);
675
+ const seen = new Set();
676
+ for (const engine of engines) {
677
+ if (!engine || typeof engine !== 'object' || Array.isArray(engine)) throw httpError(400, 'definizione engine non valida');
678
+ for (const key of Object.keys(engine)) if (!allowed.has(key)) throw httpError(400, `campo engine backup non ammesso: ${key}`);
679
+ if (typeof engine.id !== 'string' || seen.has(engine.id)) throw httpError(400, `id engine duplicato o mancante: ${engine.id || '?'}`);
680
+ if (engine.managed && engine.envKeys !== undefined) throw httpError(400, `envKeys non ammesso per engine managed: ${engine.id}`);
681
+ if (!engine.managed && (!Array.isArray(engine.envKeys)
682
+ || engine.envKeys.length > 32
683
+ || engine.envKeys.some((key) => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key))
684
+ || new Set(engine.envKeys).size !== engine.envKeys.length)) {
685
+ throw httpError(400, `envKeys non valido per engine: ${engine.id}`);
686
+ }
687
+ if (!engine.managed && Array.isArray(engine.args) && engine.args.some((arg) => {
688
+ const text = String(arg || '');
689
+ return /(?:bearer\s+|authorization\s*[:=]|(?:api[_-]?key|secret|token)\s*[:=])/i.test(text)
690
+ || /^-{1,2}(?:api[-_]?key|access[-_]?key|auth(?:orization)?[-_]?token|token|secret|password|credential)(?:$|=)/i.test(text)
691
+ || /\b(?:sk|fw|fpk|hf|zai)[-_][A-Za-z0-9._-]{8,}\b/i.test(text)
692
+ || /https?:\/\/[^\s/@:]+:[^\s/@]+@/i.test(text);
693
+ })) {
694
+ throw httpError(400, `argomento potenzialmente segreto non ammesso per engine: ${engine.id}`);
695
+ }
696
+ seen.add(engine.id);
697
+ }
698
+ const defs = reloadDefs();
699
+ const conflicts = engines.filter((engine) => !!findEngine(defs, engine.id)).map((engine) => engine.id);
700
+ if (conflicts.length && opts.overwrite !== true) {
701
+ throw httpError(409, `engine già esistenti: ${conflicts.join(', ')}`, { code: 'engine-conflicts', conflicts });
702
+ }
703
+ const sessions = await refreshSessions();
704
+ const affected = defs.cells.filter((cell) => conflicts.includes(cell.engine) && sessions.has(cell.tmuxSession)).map((cell) => cell.id);
705
+ await mutate(defs, (draft) => {
706
+ for (const portable of engines) {
707
+ const index = draft.engines.findIndex((current) => current.id === portable.id);
708
+ const current = index >= 0 ? draft.engines[index] : null;
709
+ const next = { ...portable };
710
+ if (!next.managed) {
711
+ const keys = next.envKeys;
712
+ delete next.envKeys;
713
+ next.env = Object.fromEntries(keys.map((key) => [key,
714
+ current && !current.managed && Object.prototype.hasOwnProperty.call(current.env || {}, key)
715
+ ? current.env[key] : '',
716
+ ]));
717
+ }
718
+ if (index >= 0) draft.engines[index] = next; else draft.engines.push(next);
719
+ }
720
+ });
721
+ return {
722
+ ok: true, count: engines.length,
723
+ created: engines.filter((engine) => !conflicts.includes(engine.id)).map((engine) => engine.id),
724
+ replaced: conflicts, needsRestart: affected,
725
+ };
726
+ }
608
727
  async function removeCell(id, opts = {}) {
609
728
  if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
610
729
  let defs = reloadDefs();
@@ -694,7 +813,10 @@ async function createBuiltinFleet(cfg = {}) {
694
813
  ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
695
814
  })),
696
815
  managedCatalog: publicCatalog(),
697
- managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
816
+ managedConfig: {
817
+ providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env'),
818
+ providerShellPath: cfg.providerShellPath || path.join(home, '.config', 'ai-shell', 'providers.zsh'),
819
+ },
698
820
  };
699
821
  }
700
822
 
@@ -776,7 +898,7 @@ async function createBuiltinFleet(cfg = {}) {
776
898
  provider: 'builtin',
777
899
  status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
778
900
  defineEngine, editEngine, removeEngine,
779
- defineCell, editCell, removeCell, importCell, restoreCells,
901
+ defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
780
902
  schema, definitions, capabilities,
781
903
  };
782
904
  }
@@ -787,5 +909,7 @@ module.exports = {
787
909
  minimalEnv,
788
910
  promptCharsOk,
789
911
  redactSecrets,
912
+ sanitizeEarlyDiagnostic,
913
+ waitStablePane,
790
914
  MINIMAL_ENV_KEYS,
791
915
  };