@mmmbuto/nexuscrew 0.9.40 → 0.9.42

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.
@@ -5,10 +5,12 @@
5
5
  const fs = require('node:fs');
6
6
  const path = require('node:path');
7
7
  const crypto = require('node:crypto');
8
+ const toml = require('smol-toml');
8
9
  const { execFile } = require('node:child_process');
9
10
  const { ENV_KEY_RE } = require('./env-key.js');
10
11
  const { termuxRuntimePaths } = require('../runtime/env.js');
11
12
  const { readCredentialStore, safePrivateDir } = require('./credentials.js');
13
+ const { argomentiHookCodex, gateVersione, gatePiattaforma, EVENTI_CODEX } = require('./codex-hooks.js');
12
14
 
13
15
  const OLLAMA_CLOUD_MODELS = Object.freeze([
14
16
  'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
@@ -1601,15 +1603,127 @@ function knownMcpServerNames(home, cwd) {
1601
1603
  //
1602
1604
  // Forma con l'uguale, come `--mcp-config`: un solo token, cosi' nessun argomento
1603
1605
  // successivo puo' essere inghiottito.
1604
- function cellMcpArgs(home, cell) {
1606
+ function cellMcpDeny(home, cell) {
1605
1607
  const voluti = cell && Array.isArray(cell.mcp) ? cell.mcp : null;
1606
- if (!voluti) return [];
1607
- if (!voluti.length) return [`--settings=${JSON.stringify({ permissions: { deny: ['mcp__*'] } })}`];
1608
+ if (!voluti) return null;
1609
+ if (!voluti.length) return ['mcp__*'];
1608
1610
  const concessi = new Set(voluti);
1609
- const deny = [...knownMcpServerNames(home, cell.cwd)]
1611
+ return [...knownMcpServerNames(home, cell.cwd)]
1610
1612
  .filter((nome) => !concessi.has(nome)).sort().map((nome) => `mcp__${nome}`);
1611
- if (!deny.length) return [];
1612
- return [`--settings=${JSON.stringify({ permissions: { deny } })}`];
1613
+ }
1614
+
1615
+ // --- stato attivita' dagli hook ---------------------------------------------
1616
+ //
1617
+ // Le celle Claude pubblicano il proprio stato di turno con gli hook di Claude
1618
+ // Code, invece di lasciarlo dedurre dal titolo del pane — che non lo prova
1619
+ // (il glifo `✳` sta sia su una cella al lavoro sia su una ferma).
1620
+ //
1621
+ // Il canale e' lo STESSO `--settings` che la cella riceve gia' per il deny MCP:
1622
+ // un solo token composto, non due sorgenti distinte. Dove quel token oggi non
1623
+ // esiste (ramo strict) ne nasce uno che porta SOLO gli hook.
1624
+ //
1625
+ // Il client `claude` li riceve via `--settings` (sotto). Il client codex e
1626
+ // codex-vl li riceve in forma `-c` (lib/fleet/codex-hooks.js), con la stessa
1627
+ // matrice evento->stato e lo stesso scrittore — e li riceve SOLO su versioni
1628
+ // provate, perche' la fiducia dell'hook dipende dall'hash di quella versione.
1629
+ // Gli altri motori (pi, shell) restano senza canale: la UI li mostra «non
1630
+ // verificati», che e' cio' che si sa di loro. Non si inventa uno stato che
1631
+ // nessuno pubblica.
1632
+
1633
+ // Gli eventi REGISTRATI, quelli misurati su 2.1.280 nel Gate A. `StopFailure`
1634
+ // non compare perche' su quella versione NON esiste: registrarlo sarebbe una
1635
+ // riga che non scatta mai.
1636
+ const EVENTI_ATTIVITA = Object.freeze([
1637
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
1638
+ 'SubagentStop', 'Stop', 'PermissionRequest', 'Notification', 'SessionEnd',
1639
+ ]);
1640
+
1641
+ // La root dei file per-cella. `cfg.filesRoot` c'e' quando la risoluzione arriva
1642
+ // dal server (che la porta nella propria cfg); alcuni chiamanti — i test in
1643
+ // isolamento — non la passano. In quel caso la si ricava dalla STESSA fonte di
1644
+ // configurazione invece di aggiungere un parametro che nessuno passerebbe:
1645
+ // `defaults()` e' baseDefaults + env (quindi onora NEXUSCREW_FILES_ROOT) e NON
1646
+ // legge il config.json del device, che e' esattamente cio' che serve a un
1647
+ // chiamante che non ha una cfg.
1648
+ function filesRootDi(cfg) {
1649
+ if (cfg && typeof cfg.filesRoot === 'string' && cfg.filesRoot) return cfg.filesRoot;
1650
+ try {
1651
+ const r = require('../config.js').defaults().filesRoot;
1652
+ return typeof r === 'string' && r ? r : null;
1653
+ } catch (_) { return null; }
1654
+ }
1655
+
1656
+ // Quoting POSIX: il comando di un hook e' una stringa che il client esegue con
1657
+ // la shell, e percorso dello script e directory di sessione possono contenere
1658
+ // spazi. Il singolo apice si rappresenta chiudendo, escapando e riaprendo —
1659
+ // l'unico modo che funziona anche dentro una stringa gia' quotata.
1660
+ function virgolette(valore) {
1661
+ return `'${String(valore).replace(/'/g, "'\\''")}'`;
1662
+ }
1663
+
1664
+ // Comando di UN hook. `process.execPath` e' il node che sta eseguendo il
1665
+ // server: e' lo stesso interprete che deve eseguire lo script, senza dipendere
1666
+ // dal PATH della cella.
1667
+ function comandoHook(evento, script, dirSessione, generazione) {
1668
+ const parti = [
1669
+ virgolette(process.execPath), virgolette(script),
1670
+ '--event', virgolette(evento), '--dir', virgolette(dirSessione),
1671
+ ];
1672
+ if (generazione) parti.push('--gen', virgolette(generazione));
1673
+ return parti.join(' ');
1674
+ }
1675
+
1676
+ // Oggetto `hooks` per la settings della cella, oppure null quando non e'
1677
+ // iniettabile (cella senza sessione tmux: senza nome non c'e' dove scrivere).
1678
+ function cellActivityHooks(cell, cfg) {
1679
+ // Opt-out esplicito. Serve a due cose: un operatore che non vuole hook nella
1680
+ // cella, e il test che confronta gli argomenti di lancio con e senza — un
1681
+ // confronto che gira sulla STESSA strada di codice, non su una copia.
1682
+ if (cfg && cfg.activityHooks === false) return null;
1683
+ if (!cell || typeof cell.tmuxSession !== 'string' || !cell.tmuxSession) return null;
1684
+ const root = filesRootDi(cfg);
1685
+ if (!root) return null;
1686
+ const script = path.join(__dirname, '..', '..', 'bin', 'nc-activity-hook.js');
1687
+ try { if (!fs.statSync(script).isFile()) return null; } catch (_) { return null; }
1688
+ const dirSessione = path.join(root, cell.tmuxSession);
1689
+ const generazione = cfg && typeof cfg.activityGeneration === 'string' && cfg.activityGeneration
1690
+ ? cfg.activityGeneration : null;
1691
+ // QUI NON SI SCRIVE NIENTE SU DISCO. La generazione viaggia negli argv degli
1692
+ // hook e nel payload del broker; a scriverla e' `cell-exec`, che gira DENTRO
1693
+ // la sessione tmux appena creata — quindi solo per il vincitore della
1694
+ // new-session — e lo fa prima di avviare il client (cell-exec.js: la
1695
+ // generazione su disco e' quella del client che gira davvero). Scriverla qui
1696
+ // significava scriverla anche per chi stava per perdere la corsa: sul disco
1697
+ // restava la generazione del perdente, che non e' quella di nessun lancio
1698
+ // vivo, e ogni evento del vincitore veniva scartato al lettore.
1699
+ const hooks = {};
1700
+ for (const evento of EVENTI_ATTIVITA) {
1701
+ const voce = { hooks: [{ type: 'command', command: comandoHook(evento, script, dirSessione, generazione) }] };
1702
+ // `matcher: '*'` SOLO sui due eventi tool: e' la forma MISURATA nel Gate A.
1703
+ // `Notification` viaggia senza matcher di proposito — filtrare per tipo
1704
+ // dalla settings significherebbe affidarsi a una semantica di matcher che
1705
+ // non e' stata misurata, e un matcher sbagliato spegnerebbe in silenzio
1706
+ // l'unico segnale di «attesa permesso». Il filtro per tipo lo fa lo script.
1707
+ if (evento === 'PreToolUse' || evento === 'PostToolUse') voce.matcher = '*';
1708
+ hooks[evento] = [voce];
1709
+ }
1710
+ // `script` e `generazione` escono insieme agli hooks perche' servono anche al
1711
+ // canale codex, che ricompone lo stesso comando in forma `-c`.
1712
+ return { hooks, dirSessione, script, generazione };
1713
+ }
1714
+
1715
+ // L'argomento `--settings` della cella: deny MCP e hook in UN SOLO oggetto.
1716
+ // Due sorgenti distinte sarebbero tecnicamente sicure per il deny (misurato:
1717
+ // con due `--settings` vince il deny), ma un oggetto solo e' cio' che si puo'
1718
+ // confrontare campo per campo con quello di prima — ed e' il confronto che
1719
+ // dimostra che l'unica chiave aggiunta e' `hooks`.
1720
+ function cellSettingsArgs(home, cell, attivita) {
1721
+ const deny = cellMcpDeny(home, cell);
1722
+ const out = {};
1723
+ if (deny && deny.length) out.permissions = { deny };
1724
+ if (attivita) out.hooks = attivita.hooks;
1725
+ if (!Object.keys(out).length) return [];
1726
+ return [`--settings=${JSON.stringify(out)}`];
1613
1727
  }
1614
1728
 
1615
1729
  // Definizioni MCP note, per NOME -> definizione (la prima sorgente che dichiara
@@ -1743,48 +1857,132 @@ function readCodexUserToml(codexHome) {
1743
1857
  return '';
1744
1858
  }
1745
1859
 
1860
+ // Superficie codex della config utente come TABELLE strutturate. Il parse e'
1861
+ // TOML vero (smol-toml), non la regex dei soli nomi di codexSurfaceNames: al
1862
+ // writer servono i blocchi completi dei server per copiare il trasporto, e
1863
+ // nomi quotati, sottotabelle e valori composti escono corretti. Una config
1864
+ // utente presente ma non parseabile e' un errore (fail-closed): si rifiuta il
1865
+ // profilo intero invece di emetterne uno che non ridurrebbe nulla.
1866
+ function codexSurfaceTables(configToml) {
1867
+ const testo = typeof configToml === 'string' ? configToml : '';
1868
+ if (!testo.trim()) return { servers: new Map(), skills: [] };
1869
+ let doc;
1870
+ try {
1871
+ doc = toml.parse(testo);
1872
+ } catch (e) {
1873
+ const err = new Error(`config utente non parseabile come TOML: ${e.message}`);
1874
+ err.code = 'CODEX_USER_TOML_INVALID';
1875
+ throw err;
1876
+ }
1877
+ const raw = doc.mcp_servers && typeof doc.mcp_servers === 'object' && !Array.isArray(doc.mcp_servers)
1878
+ ? doc.mcp_servers
1879
+ : {};
1880
+ const servers = new Map();
1881
+ for (const [nome, tabella] of Object.entries(raw)) {
1882
+ if (tabella && typeof tabella === 'object' && !Array.isArray(tabella)) servers.set(nome, tabella);
1883
+ }
1884
+ const voci = Array.isArray(doc.skills && doc.skills.config) ? doc.skills.config : [];
1885
+ const skills = [...new Set(voci
1886
+ .filter((s) => s && typeof s === 'object' && typeof s.name === 'string' && s.name)
1887
+ .map((s) => s.name))];
1888
+ return { servers, skills };
1889
+ }
1890
+
1891
+ // Rami di trasporto MCP e chiavi MINIME che soddisfano la validazione del
1892
+ // client: per stdio basta `command`, per streamable_http solo `url`. Per uno
1893
+ // SPENTO si copia solo questo: `args`, `env`, `http_headers`,
1894
+ // `bearer_token_env_var` e simili non servono a far passare il layer e sono
1895
+ // i campi che portano segreti (un token puo stare anche in `args`) — un file
1896
+ // derivato e rigenerato non deve duplicarli. Per un on-DEMAND si copia
1897
+ // invece la definizione INTEGRALE della tabella base: quel server e
1898
+ // concesso, il segreto gli compete, e la copia identica non puo introdurre
1899
+ // invalidita' nuove (la base valida resta valida).
1900
+ const TRASPORTO_MCP_STDIO = ['command'];
1901
+ const TRASPORTO_MCP_HTTP = ['url'];
1902
+
1903
+ function ramoTrasporto(tabella) {
1904
+ if (tabella && tabella.command !== undefined) return 'stdio';
1905
+ if (tabella && tabella.url !== undefined) return 'http';
1906
+ const err = new Error('server MCP senza trasporto riconoscibile (command/url) nella config utente');
1907
+ err.code = 'CODEX_MCP_TRANSPORT_UNKNOWN';
1908
+ throw err;
1909
+ }
1910
+
1911
+ function trasportoMinimo(tabella) {
1912
+ const campi = ramoTrasporto(tabella) === 'stdio' ? TRASPORTO_MCP_STDIO : TRASPORTO_MCP_HTTP;
1913
+ const out = {};
1914
+ for (const k of campi) {
1915
+ if (tabella[k] !== undefined) out[k] = tabella[k];
1916
+ }
1917
+ return out;
1918
+ }
1919
+
1746
1920
  function writeCellCodexProfile(cellId, declared, codexHome) {
1747
1921
  const nome = `nexuscrew-${String(cellId).replace(/\./g, '_')}`;
1748
1922
  const target = path.join(codexHome, `${nome}.config.toml`);
1749
1923
  const configToml = readCodexUserToml(codexHome);
1750
- const { servers, skills } = codexSurfaceNames(configToml);
1924
+ const { servers, skills } = codexSurfaceTables(configToml);
1751
1925
  const mcpConcessi = new Set(declared.mcp || []);
1752
1926
  const ondemand = new Set(declared.ondemand || []);
1753
1927
  const skillConcesse = new Set(declared.skills || []);
1754
- const righe = [
1755
- '# Generated by NexusCrew for this cell: reduces (never widens) the surface',
1756
- '# declared in config.toml, which NexusCrew never modifies.',
1757
- ];
1928
+ const nomiServer = [...servers.keys()].sort();
1929
+ const profilo = {};
1930
+ // OGNI tabella MCP emessa e' valida ANCHE DA SOLA: il write path
1931
+ // del client (config/batchWrite) valida il layer attivo PRIMA del documento
1932
+ // fuso (config_manager_service.rs: get_active_user_layer -> validate_config),
1933
+ // quindi una tabella ridotta senza trasporto romperebbe il salvataggio.
1934
+ // Copiare il trasporto non avvia gli spenti: restano `enabled = false`, e
1935
+ // il connection manager non li itera (connection_manager.rs). Spento =
1936
+ // minimo che soddisfa la validazione (mai segreti); on-demand = definizione
1937
+ // effettiva del layer base con la sola chiave di differimento sopra.
1758
1938
  if (declared.mcp !== undefined) {
1759
- for (const s of servers.filter((n) => !mcpConcessi.has(n)).sort()) {
1760
- righe.push('', `[mcp_servers.${s}]`, 'enabled = false');
1939
+ const tabelle = {};
1940
+ for (const s of nomiServer.filter((n) => !mcpConcessi.has(n))) {
1941
+ tabelle[s] = { ...trasportoMinimo(servers.get(s)), enabled: false };
1761
1942
  }
1943
+ if (Object.keys(tabelle).length) profilo.mcp_servers = tabelle;
1762
1944
  }
1763
1945
  // `omit_tools_from` NON e' una chiave di root: in codex e' un campo della
1764
1946
  // tabella del SINGOLO server (codex-rs/config/src/mcp_types.rs:223/:364) —
1765
1947
  // a root viene ignorato in silenzio e l'on-demand non funzionerebbe mai.
1766
1948
  // Un server on-demand e' CONCESSO ma differito: enabled resta il default e
1767
- // si scrive solo la chiave di differimento; uno spento (non concesso con
1949
+ // la tabella riproduce la definizione del layer base (valida da sola) con
1950
+ // la sola chiave di differimento sopra. Uno spento (non concesso con
1768
1951
  // `mcp` dichiarata) non prende una seconda tabella: TOML vieta i duplicati
1769
1952
  // e enabled = false resta il volere piu' restrittivo. Nomi ondemand non
1770
1953
  // presenti nella config utente non generano tabelle (ignorati; la vista li
1771
1954
  // segnala).
1772
1955
  if (ondemand.size) {
1773
1956
  const spenti = declared.mcp !== undefined
1774
- ? new Set(servers.filter((n) => !mcpConcessi.has(n)))
1957
+ ? new Set(nomiServer.filter((n) => !mcpConcessi.has(n)))
1775
1958
  : new Set();
1776
- for (const s of servers.filter((n) => ondemand.has(n) && !spenti.has(n)).sort()) {
1777
- righe.push('', `[mcp_servers.${s}]`, 'omit_tools_from = ["direct"]');
1959
+ const tabelle = profilo.mcp_servers || (profilo.mcp_servers = {});
1960
+ for (const s of nomiServer.filter((n) => ondemand.has(n) && !spenti.has(n))) {
1961
+ // Anche l'on-demand deve essere valido da solo: la copia integrale del
1962
+ // layer base contiene il trasporto se il layer base e' valido; se non
1963
+ // ce l'ha, rifiuta invece di emettere una tabella invalida.
1964
+ ramoTrasporto(servers.get(s));
1965
+ tabelle[s] = { ...servers.get(s), omit_tools_from: ['direct'] };
1778
1966
  }
1967
+ if (!Object.keys(tabelle).length) delete profilo.mcp_servers;
1779
1968
  }
1780
1969
  if (declared.skills !== undefined) {
1781
- for (const s of skills.filter((n) => !skillConcesse.has(n)).sort()) {
1782
- righe.push('', '[[skills.config]]', `name = "${s}"`, 'enabled = false');
1970
+ const nonConcesse = skills.filter((n) => !skillConcesse.has(n));
1971
+ if (nonConcesse.length) {
1972
+ profilo.skills = { config: nonConcesse.map((name) => ({ name, enabled: false })) };
1783
1973
  }
1784
1974
  }
1975
+ const righe = [
1976
+ '# Generated by NexusCrew for this cell: reduces (never widens) the surface',
1977
+ '# declared in config.toml, which NexusCrew never modifies.',
1978
+ '# MCP transport fields are copied from config.toml: every table here is',
1979
+ '# valid on its own (the client validates this layer standalone on write).',
1980
+ ];
1981
+ const corpo = Object.keys(profilo).length ? toml.stringify(profilo).trimEnd() : '';
1982
+ const testo = corpo ? `${righe.join('\n')}\n\n${corpo}\n` : `${righe.join('\n')}\n`;
1785
1983
  const tmp = path.join(codexHome, `.${nome}.${crypto.randomBytes(6).toString('hex')}.tmp`);
1786
1984
  try {
1787
- fs.writeFileSync(tmp, `${righe.join('\n')}\n`, { mode: 0o600 });
1985
+ fs.writeFileSync(tmp, testo, { mode: 0o600 });
1788
1986
  fs.chmodSync(tmp, 0o600);
1789
1987
  fs.renameSync(tmp, target);
1790
1988
  } catch (e) {
@@ -2128,6 +2326,9 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2128
2326
  // I due profili Claude con configurazione privata: perdono gli MCP del file
2129
2327
  // principale e vanno ricollegati (vedi `sharedMcpArgs`).
2130
2328
  let privateProfile = false;
2329
+ // Directory di sessione degli hook di attivita': il launcher ci scrive la
2330
+ // generazione corrente. Null quando gli hook non sono iniettabili.
2331
+ let activityDir = null;
2131
2332
  // Effective permission policy: override PER-CELL PER-ENGINE (remembered) vince sul
2132
2333
  // default dell'engine. Mai si mutationa engine.managed.permissionPolicy (globale).
2133
2334
  // Pi resta sempre 'standard' (lo spec normalized rifiuta gia' unsafe per pi).
@@ -2279,8 +2480,10 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2279
2480
  // l'abbia chiesto.
2280
2481
  const { effectiveCapabilities } = require('./definitions.js');
2281
2482
  const eff = effectiveCapabilities(cell || {}, { capabilityProfiles: cfg.capabilityProfiles || null });
2483
+ const attivita = cellActivityHooks(cell, cfg);
2484
+ if (attivita) activityDir = attivita.dirSessione;
2282
2485
  if (eff.legacy || eff.declared.mcp === undefined) {
2283
- args.push(...cellMcpArgs(home, cell));
2486
+ args.push(...cellSettingsArgs(home, cell, attivita));
2284
2487
  } else {
2285
2488
  let file;
2286
2489
  try {
@@ -2300,8 +2503,48 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2300
2503
  return { ok: false, reason: motivo, mcpCellRefused: motivo };
2301
2504
  }
2302
2505
  args.push('--strict-mcp-config', `--mcp-config=${file}`);
2506
+ // Qui il `--settings` NON esisteva: nasce per gli hook e porta SOLO
2507
+ // quelli. Nessun `permissions`: in questo ramo la superficie la chiude
2508
+ // `--strict-mcp-config` con il file per cella, e aggiungere un deny
2509
+ // calcolato per enumerazione cambierebbe cio' che la cella vede — non e'
2510
+ // richiesto e non si fa.
2511
+ if (attivita) args.push(`--settings=${JSON.stringify({ hooks: attivita.hooks })}`);
2303
2512
  }
2304
2513
  } else if (spec.client === 'codex' || spec.client === 'codex-vl') {
2514
+ // Canale di attivita' per le celle codex: gli stessi eventi che le celle
2515
+ // Claude pubblicano via `--settings`, qui come hook `-c` con la fiducia che
2516
+ // il client pretende (senza, apre un dialogo di revisione e la cella resta
2517
+ // ferma su una domanda). Vince SOLO su versioni provate: su ogni altra non
2518
+ // si inietta niente e la cella torna «non verificato» come prima — un
2519
+ // degrado dichiarato, mai un dialogo che blocca il turno.
2520
+ {
2521
+ // `attivita` e' dichiarata nel ramo claude e qui non e' in scope: si
2522
+ // ricalcola. E' pura e non scrive niente su disco: calcola gli stessi
2523
+ // argv (stessa generazione) per il client codex.
2524
+ const attivita = cellActivityHooks(cell, cfg);
2525
+ // Il gate riceve il binario RISOLTO per questa cella (`info.binary`,
2526
+ // lo stesso che finisce in argv a fine funzione), non il nome del
2527
+ // client: col nome si interrogherebbe il PATH del processo, che puo'
2528
+ // essere un eseguibile DIVERSO da quello che la cella lancera'. La
2529
+ // guardia esiste per non far bloccare la cella su un dialogo di
2530
+ // revisione, e una guardia che guarda un altro binario da' il via
2531
+ // libero sbagliato: e' il caso che deve impedire.
2532
+ const piattaforma = gatePiattaforma(cfg);
2533
+ const gate = piattaforma.ok ? gateVersione(spec.client, info.binary, cfg) : piattaforma;
2534
+ if (!gate.ok) {
2535
+ if (typeof cfg.log === 'function') cfg.log(`hook codex non iniettati: ${gate.reason}`);
2536
+ } else if (attivita) {
2537
+ args.push(...argomentiHookCodex(EVENTI_CODEX,
2538
+ (evento) => comandoHook(evento, attivita.script, attivita.dirSessione, attivita.generazione)));
2539
+ // Qui si dichiara che QUESTA cella ha un canale di attivita': e' il
2540
+ // `dir` che fa viaggiare verso il supervisore il dove e il
2541
+ // con-che-generazione dell'evento di uscita (runtime.js) e la
2542
+ // scrittura di `activity.gen` da parte di cell-exec, e senza di esso un
2543
+ // `Stop` dell'ultimo turno sopravviverebbe al riavvio interno del
2544
+ // client.
2545
+ activityDir = attivita.dirSessione;
2546
+ }
2547
+ }
2305
2548
  if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
2306
2549
  else if (spec.provider === 'openai-api') env.OPENAI_API_KEY = cred.value;
2307
2550
  else if (spec.provider === 'ollama-cloud') {
@@ -2599,14 +2842,15 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2599
2842
  }
2600
2843
  }
2601
2844
  }
2602
- // Prompt su argv (0.8.47): SOLO i client che non hanno un percorso classified
2603
- // delivery. kimi.native e claude.kimi-code usano promptMode 'send-keys' con
2604
- // deliverBootstrapPrompt (readiness classificata + at-most-once): il prompt
2605
- // NON deve mai comparire nel loro argv (visibile in ps / perso dietro
2606
- // consenso/onboarding). Gli altri managed conservano il contratto argv
2607
- // (finding separato, fuori da questa patch).
2608
- const promptViaDelivery = spec.client === 'kimi'
2609
- || (spec.client === 'claude' && spec.provider === 'kimi-code');
2845
+ // Prompt su argv (0.8.47) SOLO per i client senza percorso classified
2846
+ // delivery. Dalla 0.9.41: TUTTE le claude.* passano a consegna
2847
+ // ritardata (kimi.* le avevano dal 0.8.47) — promptMode 'send-keys' con
2848
+ // deliverBootstrapPrompt (readiness classificata + at-most-once + gate
2849
+ // readiness MCP sui log cache del client): il bootstrap NON parte più
2850
+ // prima della handshake MCP e non compare in argv (visibile in ps / perso
2851
+ // dietro consenso/onboarding). vl non ha superficie prompt (file dedicato),
2852
+ // shell e' one-shot/login: nessun posizionale per loro per costruzione.
2853
+ const promptViaDelivery = spec.client === 'kimi' || spec.client === 'claude';
2610
2854
  // vl is a TUI without a prompt surface (`vl [OPTIONS]`, no prompt flag):
2611
2855
  // it NEVER receives the per-cell prompt on argv — it travels in the
2612
2856
  // per-cell file composed by the vl branch (VL_SYSTEM_APPEND_FILE).
@@ -2642,7 +2886,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2642
2886
  command = cfg.nodeExecPath || process.execPath;
2643
2887
  args.unshift(info.binary);
2644
2888
  }
2645
- return { ok: true, info, engine: {
2889
+ return { ok: true, info, activityDir, engine: {
2646
2890
  ...engine, command, args, env,
2647
2891
  promptMode: promptViaDelivery ? 'send-keys' : 'managed-argv', clientBinary: info.binary,
2648
2892
  // Canale identita' (vedi sopra): assente per i client senza REQUIRED, cosi'
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+ // Readiness MCP per-server dalla cache del client Claude Code.
3
+ //
4
+ // Il client 2.1.280 scrive, per ogni server MCP e per ogni avvio di processo,
5
+ // un log diagnostico in:
6
+ // ~/.cache/claude-cli-nodejs/<encoded-cwd>/mcp-logs-<server>/<bootUTC>.jsonl
7
+ // con encoded-cwd = ogni non-alfanumerico sostituito da '-', un file jsonl per
8
+ // boot del processo client e `sessionId` in ogni riga. Stati osservati sul
9
+ // 2.1.280 (misurato con 8 probe tmux su sessioni usa-e-getta):
10
+ // pending: "Starting connection with timeout of 30000ms"
11
+ // ready: "Successfully connected (transport: stdio) in <N>ms"
12
+ // failed: "Connection failed after <N>ms (CONNECTION_CLOSED): Connection closed"
13
+ // e/o record {"error": "..."} SENZA campo `debug` (schema variabile).
14
+ //
15
+ // QUESTO E' UN ADATTATORE VERSIONATO su client 2.1.280: il formato e'
16
+ // diagnostica del client, non un contratto. Ogni lettura e' tollerante (righe
17
+ // non-JSON o senza campi noti vengono ignorate, errori di I/O degradano a
18
+ // pending) e il modulo dichiara la versione supportata in
19
+ // ADAPTER_CLIENT_VERSION; un client futuro che cambia formato degrada a
20
+ // pending visibile, mai crash.
21
+ //
22
+ // L'insieme atteso e' quello MATERIALIZZATO della cella (chiavi del file
23
+ // cell-mcp/<cellId>.json): un server non concesso non esiste per il launch
24
+ // (disabled per costruzione, non uno stato di questo adattatore). Il boot
25
+ // corrente e' il file con nome-stamp >= notBeforeMs del launch: i log delle
26
+ // generazioni precedenti non sbloccano mai (nessun handshake vecchio).
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const os = require('node:os');
30
+
31
+ // Versione del client su cui il formato dei log e' stato misurato (probe).
32
+ const ADAPTER_CLIENT_VERSION = '2.1.280';
33
+
34
+ // Stati bounded per server e per insieme. 'degraded' = insieme non tutti
35
+ // ready ma concluso (almeno un failed/pending scaduto il budget).
36
+ const SERVER_STATES = Object.freeze(['pending', 'ready', 'failed']);
37
+ const SET_STATES = Object.freeze(['pending', 'ready', 'degraded']);
38
+
39
+ const READY_RE = /Successfully connected/;
40
+ const FAILED_RE = /Connection failed/;
41
+ const BOOT_RE = /Starting connection/;
42
+
43
+ // '2026-09-22T18:46:09.220Z' -> '2026-09-22T18-46-09-220Z': lo stesso stamp
44
+ // che il client usa per i nomi dei boot file. Ordina lessicograficamente
45
+ // come il tempo.
46
+ function bootStampOf(ms) {
47
+ return new Date(Number(ms) || 0).toISOString().replace(/[^A-Za-z0-9TZ]/g, '-');
48
+ }
49
+
50
+ function encodeCwdForCache(cwd) {
51
+ return String(cwd || '').replace(/[^A-Za-z0-9]/g, '-');
52
+ }
53
+
54
+ function clampInt(value, dflt, min, max) {
55
+ const n = Number(value);
56
+ if (!Number.isFinite(n)) return dflt;
57
+ return Math.max(min, Math.min(max, Math.trunc(n)));
58
+ }
59
+
60
+ // Stato di UN server nel boot corrente: il file piu' recente con nome-stamp
61
+ // >= notBeforeStamp decide; i boot vecchi non vengono nemmeno letti.
62
+ function readServerState(fsImpl, serverDir, notBeforeStamp) {
63
+ let names = [];
64
+ try {
65
+ names = fsImpl.readdirSync(serverDir)
66
+ .filter((n) => n.endsWith('.jsonl') && n.slice(0, -6) >= notBeforeStamp)
67
+ .sort()
68
+ .reverse();
69
+ } catch (_) {
70
+ return 'pending'; // directory assente o illeggibile: server non ancora partito
71
+ }
72
+ if (!names.length) return 'pending';
73
+ let text = '';
74
+ try {
75
+ text = fsImpl.readFileSync(path.join(serverDir, names[0]), 'utf8');
76
+ } catch (_) {
77
+ return 'pending';
78
+ }
79
+ let sawBoot = false;
80
+ for (const line of text.split('\n')) {
81
+ if (!line.trim()) continue;
82
+ let rec = null;
83
+ try {
84
+ rec = JSON.parse(line);
85
+ } catch (_) {
86
+ continue; // riga malformata: ignorata, mai crash su diagnostica
87
+ }
88
+ if (!rec || typeof rec !== 'object') continue;
89
+ const dbg = typeof rec.debug === 'string' ? rec.debug : '';
90
+ // Le failure usano la chiave `error` senza campo `debug` (misurato 2.1.280).
91
+ if (!dbg && typeof rec.error === 'string' && rec.error) return 'failed';
92
+ if (READY_RE.test(dbg)) return 'ready';
93
+ if (FAILED_RE.test(dbg)) return 'failed';
94
+ if (BOOT_RE.test(dbg)) sawBoot = true;
95
+ }
96
+ return sawBoot ? 'pending' : 'pending';
97
+ }
98
+
99
+ // Istantanea dell'insieme materializzato: {state, servers, ready, failed,
100
+ // pending, adapterClientVersion}. Con attesa vuota lo stato e' 'ready'
101
+ // (nessun server da aspettare: il gate non deve ritardare la consegna).
102
+ function readinessNow({
103
+ cacheRoot, cwd, expectedServers, notBeforeMs, fsImpl = fs,
104
+ } = {}) {
105
+ const root = typeof cacheRoot === 'string' && cacheRoot
106
+ ? cacheRoot
107
+ : path.join(os.homedir(), '.cache', 'claude-cli-nodejs');
108
+ const encoded = encodeCwdForCache(cwd);
109
+ const notBeforeStamp = bootStampOf(notBeforeMs);
110
+ const servers = {};
111
+ const attesi = Array.isArray(expectedServers) ? expectedServers : [];
112
+ for (const name of attesi) {
113
+ if (typeof name !== 'string' || !name) continue;
114
+ servers[name] = readServerState(fsImpl, path.join(root, encoded, `mcp-logs-${name}`), notBeforeStamp);
115
+ }
116
+ const ready = Object.keys(servers).filter((k) => servers[k] === 'ready').sort();
117
+ const failed = Object.keys(servers).filter((k) => servers[k] === 'failed').sort();
118
+ const pending = Object.keys(servers).filter((k) => servers[k] === 'pending').sort();
119
+ const state = attesi.length === 0
120
+ ? 'ready'
121
+ : (failed.length === 0 && pending.length === 0 ? 'ready'
122
+ : (pending.length === 0 ? 'degraded' : 'pending'));
123
+ return { state, servers, ready, failed, pending, adapterClientVersion: ADAPTER_CLIENT_VERSION };
124
+ }
125
+
126
+ // Attesa bounded dell'insieme: termina su insieme concluso (zero pending),
127
+ // su deadline, o su cancellazione. La DEADLINE e' assoluta e composta dal
128
+ // chiamante (un solo budget composto): `deadlineMs` e' l'epoch del tetto MCP,
129
+ // tipicamente launchStartMs + 20000, che consume lo stesso orologio del
130
+ // composer (readyWaitMs). Ritorna {cancelled:true} oppure l'ultima
131
+ // readinessNow piu' `waited` e `timedOut`.
132
+ async function waitMcpReadiness({
133
+ params, deadlineMs, pollMs = 500, sleepImpl, nowImpl, isCancelled, fsImpl,
134
+ } = {}) {
135
+ const sleepFn = sleepImpl || ((ms) => new Promise((r) => setTimeout(r, ms)));
136
+ const now = nowImpl || Date.now;
137
+ const deadline = Number(deadlineMs) || 0;
138
+ const started = now();
139
+ let last = null;
140
+ for (;;) {
141
+ if (typeof isCancelled === 'function' && isCancelled()) {
142
+ return { cancelled: true };
143
+ }
144
+ last = readinessNow({ ...(params || {}), fsImpl: fsImpl || (params && params.fsImpl) });
145
+ if (last.state !== 'pending') return { ...last, waited: now() - started, timedOut: false };
146
+ if (now() >= deadline) return { ...last, waited: now() - started, timedOut: true };
147
+ await sleepFn(clampInt(pollMs, 500, 50, 5000));
148
+ }
149
+ }
150
+
151
+ module.exports = {
152
+ ADAPTER_CLIENT_VERSION, SERVER_STATES, SET_STATES,
153
+ bootStampOf, encodeCwdForCache, readinessNow, waitMcpReadiness, readServerState,
154
+ };