@mmmbuto/nexuscrew 0.8.30 → 0.8.32

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-CPNWWnqN.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-CJtVoawR.css">
14
+ <script type="module" crossorigin src="/assets/index-Dw90VJRY.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CrggFFKZ.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.30"}
1
+ {"version":"0.8.32"}
@@ -30,15 +30,16 @@ const os = require('node:os');
30
30
  const path = require('node:path');
31
31
  const {
32
32
  loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
33
+ cellIdFromTmuxSession,
33
34
  resolveCwd, normalizeCwdRel, deriveCwdRel,
34
35
  } = require('./definitions.js');
35
36
  const {
36
- publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine,
37
+ publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine,
37
38
  } = require('./managed.js');
38
39
  const { validEnvKey } = require('./env-key.js');
39
40
  const { setCredential, removeCredential } = require('./credentials.js');
40
41
  const { createLaunchBroker } = require('./launch-broker.js');
41
- const { MINIMAL_ENV_KEYS } = require('../runtime/env.js');
42
+ const { MINIMAL_ENV_KEYS, termuxRuntimePaths } = require('../runtime/env.js');
42
43
  const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
43
44
 
44
45
  // Toolkit stateless + runtime estratti (behavior-preserving). I simboli di
@@ -47,7 +48,7 @@ const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
47
48
  const { createBuiltinRuntime } = require('./runtime.js');
48
49
  const {
49
50
  composeLaunchArgv, composeClientInvocation, minimalEnv, promptCharsOk,
50
- redactSecrets, sanitizeEarlyDiagnostic, waitStablePane, httpError,
51
+ redactSecrets, sanitizeEarlyDiagnostic, waitStablePane, httpError, migrateLegacyTmuxSessions,
51
52
  } = require('./launch.js');
52
53
 
53
54
  // Copia deep delle definizioni per il draft di mutazione (round-trip sicuro su disco).
@@ -83,6 +84,29 @@ function backfillShellEngine(defsPath, defs) {
83
84
  try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
84
85
  }
85
86
 
87
+ // Backfill platform-aware dell'engine Agy primario (design §4.2): installazioni
88
+ // esistenti su Linux/macOS non-Termux ricevono agy.native in modo idempotente e
89
+ // non distruttivo. Su altre piattaforme (Termux/Windows) e' un no-op: agy non e'
90
+ // un client primario supportato li' e l'utente puo comunque usarlo via shell.local
91
+ // con command per-cella. Idempotente (gia' presente -> skip), NON sovrascrive un
92
+ // id 'agy.native' gia' scelto dall'utente per altro (collisione -> skip, store
93
+ // invariato), rispetta il cap MAX_ENGINES. Non tocca CELLE: una cella esistente
94
+ // con id agy.native non cambia engine silenziosamente (il passaggio a agy.native
95
+ // avviene solo nel field gate esplicito del coordinator).
96
+ function backfillAgyEngine(defsPath, defs, cfg = {}) {
97
+ if (!defs) return defs;
98
+ const platform = cfg.platform || process.platform;
99
+ const termux = platform === 'android'
100
+ || termuxRuntimePaths(cfg.env || process.env, { platform, home: cfg.home }) !== null;
101
+ if (termux || (platform !== 'linux' && platform !== 'darwin')) return defs;
102
+ if (defs.engines.some((engine) => engine.managed?.client === 'agy')) return defs;
103
+ if (defs.engines.some((engine) => engine.id === 'agy.native')) return defs;
104
+ if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
105
+ const draft = draftFrom(defs);
106
+ draft.engines.push(defaultAgyEngine());
107
+ try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
108
+ }
109
+
86
110
  // Applica engine + modello + policy come un'unica transizione. Ogni engine ricorda
87
111
  // il proprio ultimo modello E l'ultima policy; passando a un altro engine né
88
112
  // l'uno né l'altra attraversano il confine. La policy e' PER-CELL PER-ENGINE:
@@ -215,6 +239,7 @@ async function createBuiltinFleet(cfg = {}) {
215
239
  available: false, provider: 'builtin',
216
240
  isCellSession: () => false, capabilities: () => [],
217
241
  };
242
+ const blocked = (reason, code) => ({ ...off, reason, ...(code ? { migrationCode: code } : {}) });
218
243
  if (cfg.builtinEnabled === false) return off;
219
244
 
220
245
  const home = cfg.home || os.homedir();
@@ -226,7 +251,31 @@ async function createBuiltinFleet(cfg = {}) {
226
251
  // Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7)
227
252
  let boot = loadDefinitions(defsPath);
228
253
  if (!boot) return off;
229
- if (!readonly()) boot = backfillShellEngine(defsPath, boot);
254
+ if (!readonly()) {
255
+ // La migrazione precede QUALUNQUE backfill/scrittura: loadDefinitions
256
+ // normalizza i nomi legacy soltanto in memoria. Se il rename e' ambiguo o
257
+ // fallisce, fleet.json resta byte-invariato e la Fleet non puo creare una
258
+ // seconda sessione safe accanto a quella legacy.
259
+ let migration;
260
+ try {
261
+ migration = await migrateLegacyTmuxSessions(tmuxBin, boot, { readonly: false });
262
+ } catch (error) {
263
+ const code = /^[A-Z0-9_]+$/.test(String(error?.code || ''))
264
+ ? String(error.code) : 'TMUX_MIGRATION_FAILED';
265
+ const detail = String(error?.message || 'migrazione tmux fallita')
266
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 320);
267
+ return blocked(`${detail} [${code}]`, code);
268
+ }
269
+ if (migration.needsPersistence) {
270
+ try { boot = atomicWrite(defsPath, boot); }
271
+ catch (_) {
272
+ return blocked('migrazione tmux completata ma fleet.json non e persistibile [TMUX_MIGRATION_PERSIST_FAILED]',
273
+ 'TMUX_MIGRATION_PERSIST_FAILED');
274
+ }
275
+ }
276
+ boot = backfillShellEngine(defsPath, boot);
277
+ boot = backfillAgyEngine(defsPath, boot, cfg);
278
+ }
230
279
 
231
280
  // Adopt or create the shared server before exposing a mutable Fleet. Reapply
232
281
  // before every lifecycle mutation so an accidental config reload cannot
@@ -585,15 +634,18 @@ async function createBuiltinFleet(cfg = {}) {
585
634
  const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
586
635
  function deriveCellId(session, cells) {
587
636
  const raw = String(session || '');
588
- // Una sessione legacy canonica cloud-X si adotta come cella X. Usare
589
- // direttamente cloud-X come id produrrebbe cloud-cloud-X e fallirebbe la
590
- // validazione; un id esplicito diverso continua invece a essere rifiutato.
591
- const canonicalSuffix = raw.startsWith('cloud-') ? raw.slice('cloud-'.length) : '';
592
- let base = (canonicalSuffix && CELL_ID_RE.test(canonicalSuffix) ? canonicalSuffix : raw)
593
- .replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'cell';
637
+ // Sessione canonica fleet (v2 per id puntati, o cloud-<id> senza punto):
638
+ // il reverse ricostruisce il cellId umano esatto (round-trip verificato).
639
+ const reversed = cellIdFromTmuxSession(raw);
640
+ if (reversed) {
641
+ const used = new Set(cells.map((c) => c.id));
642
+ if (!used.has(reversed)) return reversed;
643
+ return reversed; // id gia' in uso -> il caller produrra' un 409 esplicito
644
+ }
645
+ // fallback: nome non canonico (override custom, "jarvis", ...): sanitizza
646
+ let base = raw.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'cell';
594
647
  const used = new Set(cells.map((c) => c.id));
595
648
  if (!used.has(base)) return base;
596
- if (canonicalSuffix) return base; // il caller produrrà un 409 esplicito
597
649
  for (let i = 2; i < 100; i += 1) {
598
650
  const candidate = `${base.slice(0, Math.max(1, 32 - String(i).length - 1))}-${i}`;
599
651
  if (!used.has(candidate)) return candidate;
@@ -756,7 +808,7 @@ async function createBuiltinFleet(cfg = {}) {
756
808
  rc: { type: 'boolean', required: false, default: false },
757
809
  managed: {
758
810
  type: 'object', requiredFor: 'managed',
759
- client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi', 'shell'] },
811
+ client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi', 'agy', 'shell'] },
760
812
  provider: { type: 'catalog', source: 'managedCatalog' },
761
813
  credentialProfile: { type: 'string', required: false, max: 32 },
762
814
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
@@ -820,6 +872,7 @@ async function createBuiltinFleet(cfg = {}) {
820
872
  module.exports = {
821
873
  createBuiltinFleet,
822
874
  backfillShellEngine,
875
+ backfillAgyEngine,
823
876
  resolveCellCwd,
824
877
  composeLaunchArgv,
825
878
  composeClientInvocation,
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // lib/fleet/cell-hold.js — placeholder inerte per l'avvio staged del pane Fleet.
4
+ // (NexusCrew 0.8.31 — design piano §3.3: identita tmux sicura)
5
+ //
6
+ // Resta in attesa finche' `respawn-pane -k` non lo sostituisce con il vero client
7
+ // (cell-exec). Crea il pane e la finestra in modo deterministico cosi' il runtime
8
+ // puo' armare `remain-on-exit` window-local PRIMA di lanciare il child reale:
9
+ // cio' impedisce a un child rapido di chiudere la sessione durante il setup. La
10
+ // normalizzazione dei punti nei nomi tmux e gestita separatamente dal mapping v2.
11
+ //
12
+ // Vincoli (piano §3.3): NESSUNA shell interattiva, NESSUN rc/alias/plugin utente,
13
+ // NESSUN dato sensibile, NESSUN argomento interpretato. argv diretto
14
+ // (process.execPath + path assoluto di questo file), puramente bloccante. Non e'
15
+ // il comando reale della cella: command/env/prompt/payload broker non appaiono
16
+ // mai nell'argv tmux.
17
+ //
18
+ // Uscita silenziosa sul segnale di terminazione inviato da respawn-pane -k.
19
+
20
+ setInterval(() => {}, 60000);
@@ -33,9 +33,10 @@ const MAX_CELL_COMMAND_LEN = 4096;
33
33
  const MAX_TMUXSESSION_LEN = 64;
34
34
 
35
35
  const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
36
- const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/; // cell id: ammette maiuscole
36
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/; // cell id: ammette maiuscole (il punto e' un id umano valido)
37
37
  const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // identificatore env POSIX-like
38
- const TMUX_NAME_RE = /^[\w.-]{1,64}$/; // come lifecycle NAME_RE
38
+ const TMUX_NAME_RE = /^[\w.-]{1,64}$/; // parsing: ammette il punto (legacy puntato da migrare)
39
+ const TMUX_SAFE_NAME_RE = /^[\w-]{1,64}$/; // scrittura: nomi tmux-safe (NO punto: tmux lo normalizza in '_')
39
40
 
40
41
  // Denylist dura di chiavi loader/runtime (design 9a): chi le imposta altera
41
42
  // l'esecuzione controllata dal service -> rifiuta l'INTERO documento.
@@ -76,11 +77,64 @@ function validTmuxName(name) {
76
77
  && !name.startsWith('-');
77
78
  }
78
79
 
80
+ // Un nome tmux e' "safe" se non contiene delimitatori che tmux rifiuta,
81
+ // interpreta o normalizza. L'unico raggiungibile via CELL_ID_RE/TMUX_NAME_RE e'
82
+ // il punto ('.' -> '_' silenzioso, poi trattato come separatore pane nei target).
83
+ // `:` non e' ammesso dai RE, e validTmuxName rifiuta gia' il leading '-': per
84
+ // costruzione il punto e' dunque l'unico carattere ostile coperto da questo gate.
85
+ function isTmuxSafeName(name) {
86
+ return validTmuxName(name) && TMUX_SAFE_NAME_RE.test(name);
87
+ }
88
+
89
+ // Deriva il nome sessione tmux CANONICO di una cella. Puro, iniettivo, reversibile.
90
+ // - ID senza punto: storico `cloud-<id>` (le sessioni esistenti non vengono
91
+ // rinominate; il namespace e' tmux-safe perche' l'id stesso non ha punto).
92
+ // - ID con punto: mapping v2 dot-free (design §3.1.5):
93
+ // raw = base64url(UTF-8 id), senza padding '='
94
+ // n = lunghezza di raw su due cifre
95
+ // padded = raw right-padded con '-' fino a 43 caratteri
96
+ // session= "cloud-v2-" + n + "-" + padded (55 char, charset [A-Za-z0-9_-])
97
+ // Il suffisso v2 supera sempre i 32 char di un id, quindi il namespace e'
98
+ // disgiunto da `cloud-<id>`. base64url di un id ASCII <=32 byte e' <=43 char,
99
+ // cosi' `padded` non tronca mai. Ritorna null se l'id non e' un cell id valido.
100
+ function tmuxSessionForCell(cellId) {
101
+ if (typeof cellId !== 'string' || !CELL_ID_RE.test(cellId)) return null;
102
+ if (!cellId.includes('.')) return `cloud-${cellId}`;
103
+ const raw = Buffer.from(cellId, 'utf8').toString('base64url'); // base64url: nessun padding '='
104
+ const n = String(raw.length).padStart(2, '0').slice(-2);
105
+ const padded = (raw + '-'.repeat(43)).slice(0, 43);
106
+ return `cloud-v2-${n}-${padded}`;
107
+ }
108
+
109
+ // Reverse di tmuxSessionForCell: dal nome sessione tmux ricostruisce il cellId
110
+ // umano quando il nome e' canonico (v2 decodificato, o cloud-<id> senza punto).
111
+ // Verifica il round-trip esatto (tmuxSessionForCell(id) === session) per evitare
112
+ // falsi positivi. Ritorna null per nomi non canonici (es. override custom, "jarvis").
113
+ function cellIdFromTmuxSession(session) {
114
+ if (typeof session !== 'string' || !session) return null;
115
+ const m = /^cloud-v2-(\d{2})-(.{43})$/.exec(session);
116
+ if (m) {
117
+ const n = Number(m[1]);
118
+ if (Number.isInteger(n) && n >= 1 && n <= 43) {
119
+ try {
120
+ const id = Buffer.from(m[2].slice(0, n), 'base64url').toString('utf8');
121
+ if (CELL_ID_RE.test(id) && tmuxSessionForCell(id) === session) return id;
122
+ } catch (_) { /* fall through */ }
123
+ }
124
+ return null;
125
+ }
126
+ if (session.startsWith('cloud-')) {
127
+ const suffix = session.slice('cloud-'.length);
128
+ if (CELL_ID_RE.test(suffix) && !suffix.includes('.') && tmuxSessionForCell(suffix) === session) return suffix;
129
+ }
130
+ return null;
131
+ }
132
+
79
133
  // ---------------------------------------------------------------------------
80
134
  // parseDefinitions(raw) -> {schemaVersion, engines, cells} | null
81
135
  // Accetta stringa JSON o oggetto gia' parsato. Strict + fail-closed.
82
136
  // ---------------------------------------------------------------------------
83
- function parseDefinitions(raw) {
137
+ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
84
138
  try {
85
139
  let d;
86
140
  if (typeof raw === 'string') {
@@ -110,19 +164,32 @@ function parseDefinitions(raw) {
110
164
  }
111
165
 
112
166
  const tmuxSeen = new Set();
167
+ const legacyTmuxSeen = new Set();
168
+ const legacyTmuxSessions = new Map();
113
169
  const cellIds = new Set();
114
170
  const cells = [];
115
171
  for (const c of d.cells) {
116
- const cell = parseCell(c, engineIds, engineMap);
172
+ const cell = parseCell(c, engineIds, engineMap, { allowLegacyTmuxNames });
117
173
  if (!cell) return null;
118
174
  if (cellIds.has(cell.id)) return null; // id cell univoco
119
175
  cellIds.add(cell.id);
120
176
  if (tmuxSeen.has(cell.tmuxSession)) return null; // tmuxSession univoco
121
177
  tmuxSeen.add(cell.tmuxSession);
178
+ if (cell.legacyTmuxSession) {
179
+ if (legacyTmuxSeen.has(cell.legacyTmuxSession)) return null;
180
+ legacyTmuxSeen.add(cell.legacyTmuxSession);
181
+ legacyTmuxSessions.set(cell.id, cell.legacyTmuxSession);
182
+ }
122
183
  cells.push(cell);
123
184
  }
124
185
 
125
- return { schemaVersion: SCHEMA_VERSION, engines, cells };
186
+ const parsed = { schemaVersion: SCHEMA_VERSION, engines, cells };
187
+ // Metadato solo in memoria: serve al bootstrap per rinominare una sessione
188
+ // legacy PRIMA di persistere il nome safe. Non entra in JSON, draft o API.
189
+ Object.defineProperty(parsed, 'legacyTmuxSessions', {
190
+ value: legacyTmuxSessions, enumerable: false, configurable: false,
191
+ });
192
+ return parsed;
126
193
  } catch (_) {
127
194
  return null; // fail-closed: qualunque eccezione inattesa -> null, MAI throw
128
195
  }
@@ -218,7 +285,7 @@ function parseEngine(e) {
218
285
  return out;
219
286
  }
220
287
 
221
- function parseCell(c, engineIds, engineMap = new Map()) {
288
+ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames = true } = {}) {
222
289
  if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
223
290
 
224
291
  // id
@@ -312,17 +379,38 @@ function parseCell(c, engineIds, engineMap = new Map()) {
312
379
  }
313
380
 
314
381
  // tmuxSession: campo esplicito o derivato da id. UNIVOCO (check in caller).
315
- // Namespace cloud-* RISERVATO al fleet (coerente con lifecycle.js: le celle
316
- // sono cloud-<Cell>): un override esplicito che usa cloud-* e' rifiutato,
317
- // PERCHE' accettare un cloud-* diverso dal proprio aliaserebbe la sessione
318
- // di un'altra cella fleet. Si ammette SOLO il derivato canonico cloud-<id>
319
- // della cella stessa (forma normale, sopravvive al round-trip su disco).
320
- const canonical = `cloud-${c.id}`;
382
+ // Il nome CANONICO e' tmux-safe (v2 per id puntati): tmux normalizza '.' in
383
+ // '_' nei nomi sessione, per cui `cloud-agy.native` diverrebbe `cloud-agy_native`
384
+ // e ogni target `-t =cloud-agy.native:` fallirebbe deterministicamente.
385
+ // - override === canonico safe (post-migrazione): round-trip.
386
+ // - qualunque override legacy con punto e' ammesso SOLO dal percorso di
387
+ // lettura/migrazione, normalizzato in memoria al canonico safe e conservato
388
+ // come metadato non enumerabile. atomicWrite usa il parser strict e quindi
389
+ // non puo reintrodurlo come nuovo valore.
390
+ // - override cloud-* di altra cella: rifiutato (aliaserebbe sessioni altrui).
391
+ // - override custom con punto: rifiutato (nuovo nome non tmux-safe).
392
+ // - override custom senza punto: ammesso (gia' tmux-safe).
393
+ // La restrizione del punto vive in SCRITTURA (derivazione/override), non in
394
+ // lettura: validTmuxName resta permissivo, cosi' uno store legacy con
395
+ // tmuxSession puntato viene normalizzato, non scartato.
396
+ const canonical = tmuxSessionForCell(c.id);
397
+ const legacy = `cloud-${c.id}`;
321
398
  let tmuxSession;
399
+ let legacyTmuxSession = '';
322
400
  if (c.tmuxSession !== undefined) {
323
401
  if (typeof c.tmuxSession !== 'string' || !validTmuxName(c.tmuxSession)) return null;
324
- if (/^cloud-/i.test(c.tmuxSession) && c.tmuxSession !== canonical) return null; // alias cloud-* -> null
325
- tmuxSession = c.tmuxSession;
402
+ if (c.tmuxSession.includes('.')) {
403
+ if (!allowLegacyTmuxNames) return null;
404
+ if (/^cloud-/i.test(c.tmuxSession) && c.tmuxSession !== legacy) return null;
405
+ legacyTmuxSession = c.tmuxSession;
406
+ tmuxSession = canonical;
407
+ } else if (c.tmuxSession === canonical || c.tmuxSession === legacy) {
408
+ tmuxSession = canonical;
409
+ } else if (/^cloud-/i.test(c.tmuxSession)) {
410
+ return null; // alias cloud-* di altra cella
411
+ } else {
412
+ tmuxSession = c.tmuxSession; // override custom tmux-safe (no punto)
413
+ }
326
414
  } else {
327
415
  tmuxSession = canonical;
328
416
  }
@@ -334,6 +422,11 @@ function parseCell(c, engineIds, engineMap = new Map()) {
334
422
  if (permissionPolicies) out.permissionPolicies = permissionPolicies;
335
423
  if (commands && Object.keys(commands).length) out.commands = commands;
336
424
  if (prompt !== undefined) out.prompt = prompt;
425
+ if (legacyTmuxSession) {
426
+ Object.defineProperty(out, 'legacyTmuxSession', {
427
+ value: legacyTmuxSession, enumerable: false, configurable: false,
428
+ });
429
+ }
337
430
  return out;
338
431
  }
339
432
 
@@ -469,7 +562,9 @@ function atomicWrite(p, data) {
469
562
  else throw e; // inclusi i nostri 'refuse to write'
470
563
  }
471
564
 
472
- const parsed = parseDefinitions(data);
565
+ // Le letture devono accettare store legacy per poterli migrare; le scritture
566
+ // invece sono sempre tmux-safe e rifiutano qualunque nuovo nome con punto.
567
+ const parsed = parseDefinitions(data, { allowLegacyTmuxNames: false });
473
568
  if (!parsed) {
474
569
  backupPredecessor(p); // conserva il precedente per recovery/forensics
475
570
  throw new Error('definizioni fleet non valide: validazione fallita');
@@ -505,6 +600,9 @@ module.exports = {
505
600
  loadDefinitions,
506
601
  atomicWrite,
507
602
  validTmuxName,
603
+ isTmuxSafeName,
604
+ tmuxSessionForCell,
605
+ cellIdFromTmuxSession,
508
606
  CAPS,
509
607
  // Costanti esposte anche piatte (comode per la UI/schema e i test)
510
608
  SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
@@ -130,7 +130,10 @@ function createLaunchBroker(cfg = {}) {
130
130
  try { if (socketPath) fs.unlinkSync(socketPath); } catch (_) {}
131
131
  }
132
132
 
133
- return { issue, close, pendingCount: () => pending.size };
133
+ // Revoca esplicita di un nonce pendente (design §3.3): se il respawn-pane
134
+ // fallisce dopo issue(), il runtime consuma/revoca il ticket subito invece di
135
+ // attenderne il TTL. expire() e' gia' no-op su nonce mancante/scaduto.
136
+ return { issue, close, revoke: expire, pendingCount: () => pending.size };
134
137
  }
135
138
 
136
139
  module.exports = { createLaunchBroker, runtimeDir, ensureRuntimeDir, encodePayload, MAX_PAYLOAD };
@@ -23,6 +23,7 @@ const path = require('node:path');
23
23
  const { execFile } = require('node:child_process');
24
24
  const { minimalRuntimeEnv } = require('../runtime/env.js');
25
25
  const { codeOf, phaseOf } = require('./causes.js');
26
+ const { isTmuxSafeName, tmuxSessionForCell } = require('./definitions.js');
26
27
 
27
28
  // Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
28
29
  // non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
@@ -129,6 +130,128 @@ function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
129
130
 
130
131
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
131
132
 
133
+ function tmuxMigrationError(code, message) {
134
+ const error = new Error(message);
135
+ error.code = code;
136
+ return error;
137
+ }
138
+
139
+ function boundedTmuxFailure(result) {
140
+ return String(result?.stderr || result?.err?.message || 'errore tmux')
141
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 240) || 'errore tmux';
142
+ }
143
+
144
+ // Migrazione identita' tmux (design §3.2). Prima costruisce e valida TUTTO il
145
+ // piano: nessun rename parte se una sessione legacy normalizzata e' rivendicata
146
+ // da piu celle (es. a.b vs a_b), se legacy+target safe coesistono o se il target
147
+ // non e' tmux-safe. Solo dopo il preflight rinomina via `$N`; qualunque errore e'
148
+ // propagato con causa bounded e impedisce la persistenza di fleet.json. I rename
149
+ // gia riusciti prima di un errore operativo restano comunque idempotenti al boot
150
+ // successivo, mentre lo store precedente rimane intatto.
151
+ async function migrateLegacyTmuxSessions(tmuxBin, defs, { readonly = false } = {}) {
152
+ const cells = defs && Array.isArray(defs.cells) ? defs.cells : [];
153
+ const legacyMap = defs?.legacyTmuxSessions instanceof Map ? defs.legacyTmuxSessions : new Map();
154
+ const candidates = [];
155
+ for (const cell of cells) {
156
+ if (!cell || typeof cell.id !== 'string') continue;
157
+ const legacyRaw = legacyMap.get(cell.id) || (cell.id.includes('.') ? `cloud-${cell.id}` : '');
158
+ if (!legacyRaw) continue;
159
+ const safe = cell.tmuxSession || tmuxSessionForCell(cell.id);
160
+ if (!isTmuxSafeName(safe)) {
161
+ throw tmuxMigrationError('TMUX_MIGRATION_INVALID_TARGET',
162
+ `migrazione tmux bloccata per ${cell.id}: target safe non valido`);
163
+ }
164
+ candidates.push({ id: cell.id, legacyRaw, legacyNorm: legacyRaw.replace(/\./g, '_'), safe });
165
+ }
166
+ if (!candidates.length || readonly) {
167
+ return {
168
+ migrated: [], reason: readonly ? 'readonly' : 'none',
169
+ needsPersistence: legacyMap.size > 0,
170
+ };
171
+ }
172
+ const listing = await tmuxExec(tmuxBin,
173
+ ['list-sessions', '-F', '#{session_id}\t#{session_name}'], { env: minimalEnv() });
174
+ if (listing.err) {
175
+ const detail = boundedTmuxFailure(listing);
176
+ if (/no server running|failed to connect|connection refused|no such file.*tmux/i.test(detail)) {
177
+ return { migrated: [], reason: 'no-tmux-server', needsPersistence: legacyMap.size > 0 };
178
+ }
179
+ throw tmuxMigrationError('TMUX_MIGRATION_LIST_FAILED',
180
+ `migrazione tmux: elenco sessioni non disponibile (${detail})`);
181
+ }
182
+ const realByName = new Map(); // session_name -> session_id ($N)
183
+ for (const line of listing.stdout.split('\n')) {
184
+ const [sid, sname] = line.split('\t');
185
+ if (sid && sname) realByName.set(String(sname).trim(), String(sid).trim());
186
+ }
187
+
188
+ const ownersBySafe = new Map(cells.map((cell) => [cell.tmuxSession, cell.id]));
189
+ const claimsByReal = new Map();
190
+ for (const candidate of candidates) {
191
+ for (const name of new Set([candidate.legacyRaw, candidate.legacyNorm])) {
192
+ if (!realByName.has(name)) continue;
193
+ const claims = claimsByReal.get(name) || [];
194
+ claims.push(candidate);
195
+ claimsByReal.set(name, claims);
196
+ }
197
+ }
198
+
199
+ const activeNamesByCell = new Map();
200
+ for (const [realName, claims] of claimsByReal) {
201
+ for (const claim of claims) {
202
+ const names = activeNamesByCell.get(claim.id) || [];
203
+ names.push(realName);
204
+ activeNamesByCell.set(claim.id, names);
205
+ }
206
+ }
207
+ for (const [cellId, names] of activeNamesByCell) {
208
+ if (names.length > 1) {
209
+ throw tmuxMigrationError('TMUX_MIGRATION_AMBIGUOUS',
210
+ `migrazione tmux ambigua per ${cellId}: piu sessioni legacy (${names.join(', ')})`);
211
+ }
212
+ }
213
+
214
+ const operations = [];
215
+ for (const [realName, claims] of claimsByReal) {
216
+ if (claims.length !== 1) {
217
+ throw tmuxMigrationError('TMUX_MIGRATION_AMBIGUOUS',
218
+ `migrazione tmux ambigua: ${realName} corrisponde a ${claims.map((c) => c.id).join(', ')}`);
219
+ }
220
+ const candidate = claims[0];
221
+ const owner = ownersBySafe.get(realName);
222
+ if (owner && owner !== candidate.id) {
223
+ throw tmuxMigrationError('TMUX_MIGRATION_AMBIGUOUS',
224
+ `migrazione tmux ambigua: ${realName} e gia assegnata a ${owner}`);
225
+ }
226
+ const legacyId = realByName.get(realName);
227
+ if (!/^\$[0-9]+$/.test(legacyId || '')) {
228
+ throw tmuxMigrationError('TMUX_MIGRATION_INVALID_ID',
229
+ `migrazione tmux bloccata per ${candidate.id}: session ID non valido`);
230
+ }
231
+ const safeId = realByName.get(candidate.safe);
232
+ if (safeId && safeId !== legacyId) {
233
+ throw tmuxMigrationError('TMUX_MIGRATION_TARGET_EXISTS',
234
+ `migrazione tmux bloccata per ${candidate.id}: target ${candidate.safe} gia esistente`);
235
+ }
236
+ operations.push({ ...candidate, legacyId, realName });
237
+ }
238
+
239
+ const migrated = [];
240
+ for (const operation of operations) {
241
+ const rn = await tmuxExec(tmuxBin,
242
+ ['rename-session', '-t', operation.legacyId, operation.safe], { env: minimalEnv() });
243
+ if (rn.err) {
244
+ throw tmuxMigrationError('TMUX_MIGRATION_RENAME_FAILED',
245
+ `migrazione tmux fallita per ${operation.id} (${boundedTmuxFailure(rn)})`);
246
+ }
247
+ migrated.push({ id: operation.id, from: operation.realName, to: operation.safe });
248
+ }
249
+ return {
250
+ migrated, reason: 'ok',
251
+ needsPersistence: legacyMap.size > 0 || migrated.length > 0,
252
+ };
253
+ }
254
+
132
255
  // Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
133
256
  // rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
134
257
  // iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
@@ -243,6 +366,7 @@ module.exports = {
243
366
  promptCharsOk,
244
367
  composeClientInvocation,
245
368
  composeLaunchArgv,
369
+ migrateLegacyTmuxSessions,
246
370
  waitAlive,
247
371
  waitStablePane,
248
372
  injectPrompt,
@@ -61,7 +61,7 @@ const ALIBABA_PI_MODELS = Object.freeze([
61
61
 
62
62
  const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
63
63
  const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
64
- const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', shell: 'Shell' });
64
+ const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', agy: 'Agy', shell: 'Shell' });
65
65
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
66
66
 
67
67
  function validBaseUrl(value) {
@@ -128,6 +128,16 @@ const CATALOG = Object.freeze([
128
128
  { id: 'pi.ollama', client: 'pi', provider: 'ollama', label: 'Ollama local', auth: 'none', protocol: 'openai-completions', piProvider: 'ollama', requiresModel: true, core: true, piExtension: { baseUrl: 'http://127.0.0.1:11434/v1', apiKey: 'ollama' } },
129
129
  { id: 'pi.zai', client: 'pi', provider: 'zai', label: 'Z.AI', auth: 'ZAI_API_KEY', protocol: 'pi_native', piProvider: 'zai', core: true },
130
130
  { id: 'pi.custom', client: 'pi', provider: 'custom', label: 'Custom provider', auth: 'dynamic', protocol: 'openai-responses', protocols: ['openai-responses', 'anthropic-messages', 'openai-completions', 'google-generative-ai'], custom: true, core: true },
131
+
132
+ // Agy: client gestito primario (Linux/macOS non-Termux). Auth delegata al
133
+ // login locale del client (nessuno store letto/copiato). Nessun remote-control
134
+ // NexusCrew; permission default 'standard' (unsafe aggiunge --dangerously-skip-
135
+ // permissions, allineato agli altri client). NON e' un default seed (non va nel
136
+ // fleet.json fresco su piattaforme non supportate): viene aggiunto solo da un
137
+ // backfill platform-aware (builtin.js) su Linux/macOS non-Termux. Qui figura
138
+ // (core) per la UI; describeManaged lo dichiara non configurato altrove.
139
+ // Termux/Windows restano fuori dal primary: l'utente usa agy via shell.local.
140
+ { id: 'agy.native', client: 'agy', provider: 'native', label: 'Agy', auth: 'login', protocol: 'agy_native', core: true },
131
141
  ]);
132
142
 
133
143
  function profileFor(client, provider, credentialProfile) {
@@ -202,6 +212,18 @@ function defaultShellEngine() {
202
212
  };
203
213
  }
204
214
 
215
+ // Engine Agy per il backfill platform-aware (builtin.js): standard di default,
216
+ // auth delegata al login locale, niente remote-control. Non e' un default seed.
217
+ function defaultAgyEngine() {
218
+ const profile = CATALOG.find((entry) => entry.id === 'agy.native');
219
+ return {
220
+ id: profile.id,
221
+ label: CLIENT_LABELS.agy,
222
+ rc: false,
223
+ managed: { client: 'agy', provider: 'native', model: '', permissionPolicy: 'standard' },
224
+ };
225
+ }
226
+
205
227
  function parseAssignments(raw) {
206
228
  const out = {};
207
229
  for (const line of raw.split(/\r?\n/)) {
@@ -485,17 +507,30 @@ function describeManaged(spec, cfg = {}) {
485
507
  const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom'
486
508
  && profile.delegatePiAuth !== false;
487
509
  const authConfigured = delegatedPiAuth || profile.auth === 'login' || profile.auth === 'none' || !!cred.value;
510
+ let configured = !!binary && authConfigured;
511
+ let reason = !binary ? `client ${profile.client} not found` : (!authConfigured
512
+ ? `credential ${cred.envKey} missing — set it on this device`
513
+ : 'ready');
514
+ // Agy e' un client primario supportato solo su Linux/macOS non-Termux.
515
+ // Rilevazione Termux via termuxRuntimePaths (non solo process.platform): un
516
+ // Node che riporta 'linux' sotto proot/Termux viene comunque intercettato.
517
+ if (normalized.client === 'agy') {
518
+ const platform = cfg.platform || process.platform;
519
+ const termux = platform === 'android' || termuxRuntimePaths(cfg.env || process.env, { platform, home }) !== null;
520
+ if (termux || (platform !== 'linux' && platform !== 'darwin')) {
521
+ configured = false;
522
+ reason = 'agy non supportato su questa piattaforma (usa shell.local con command agy)';
523
+ }
524
+ }
488
525
  return {
489
526
  client: profile.client, clientLabel: CLIENT_LABELS[profile.client], provider: profile.provider,
490
527
  credentialProfile: normalized.credentialProfile || '', model: normalized.model,
491
528
  permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
492
529
  endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
493
530
  credentialSource: authConfigured ? cred.source : 'missing',
494
- configured: !!binary && authConfigured, models: [...(profile.models || [])], defaultModel: profile.model || '',
531
+ configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
495
532
  binary: binary || '', displayName: normalized.displayName || profile.label,
496
- reason: !binary ? `client ${profile.client} not found` : (!authConfigured
497
- ? `credential ${cred.envKey} missing — set it on this device`
498
- : 'ready'),
533
+ reason,
499
534
  };
500
535
  }
501
536
 
@@ -644,7 +679,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
644
679
  if (spec.client === 'pi' || spec.client === 'shell') effectivePolicy = 'standard';
645
680
  info.permissionPolicy = effectivePolicy;
646
681
  if (effectivePolicy === 'unsafe') {
647
- if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
682
+ if (spec.client === 'claude' || spec.client === 'agy') args.push('--dangerously-skip-permissions');
648
683
  if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
649
684
  }
650
685
  let shellOneShot = false;
@@ -770,6 +805,13 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
770
805
  if (spec.provider !== 'native') args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
771
806
  if (model) args.push('--model', model);
772
807
  if (spec.provider === 'alibaba-token-plan' && model === 'qwen3.8-max-preview') args.push('--thinking', 'xhigh');
808
+ } else if (spec.client === 'agy') {
809
+ // Agy delega l'autenticazione al proprio login locale (auth: 'login'): niente
810
+ // env provider, niente credenziali copiate. Argv diretto: --model prima del
811
+ // prompt; --prompt-interactive e' l'ultima coppia (il prompt value e' accodato
812
+ // dal push generico qui sotto). Senza prompt parte il TUI interattivo `agy`.
813
+ if (model) args.push('--model', model);
814
+ if (cell?.prompt) args.push('--prompt-interactive');
773
815
  }
774
816
  if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
775
817
  let command = info.binary;
@@ -800,7 +842,7 @@ module.exports = {
800
842
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
801
843
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
802
844
  CLIENT_LABELS, normalizeManagedSpec,
803
- defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
845
+ defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
804
846
  discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
805
847
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
806
848
  ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,