@mmmbuto/nexuscrew 0.9.40 → 0.9.41

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,6 +5,7 @@
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');
@@ -1601,15 +1602,113 @@ function knownMcpServerNames(home, cwd) {
1601
1602
  //
1602
1603
  // Forma con l'uguale, come `--mcp-config`: un solo token, cosi' nessun argomento
1603
1604
  // successivo puo' essere inghiottito.
1604
- function cellMcpArgs(home, cell) {
1605
+ function cellMcpDeny(home, cell) {
1605
1606
  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__*'] } })}`];
1607
+ if (!voluti) return null;
1608
+ if (!voluti.length) return ['mcp__*'];
1608
1609
  const concessi = new Set(voluti);
1609
- const deny = [...knownMcpServerNames(home, cell.cwd)]
1610
+ return [...knownMcpServerNames(home, cell.cwd)]
1610
1611
  .filter((nome) => !concessi.has(nome)).sort().map((nome) => `mcp__${nome}`);
1611
- if (!deny.length) return [];
1612
- return [`--settings=${JSON.stringify({ permissions: { deny } })}`];
1612
+ }
1613
+
1614
+ // --- stato attivita' dagli hook ---------------------------------------------
1615
+ //
1616
+ // Le celle Claude pubblicano il proprio stato di turno con gli hook di Claude
1617
+ // Code, invece di lasciarlo dedurre dal titolo del pane — che non lo prova
1618
+ // (il glifo `✳` sta sia su una cella al lavoro sia su una ferma).
1619
+ //
1620
+ // Il canale e' lo STESSO `--settings` che la cella riceve gia' per il deny MCP:
1621
+ // un solo token composto, non due sorgenti distinte. Dove quel token oggi non
1622
+ // esiste (ramo strict) ne nasce uno che porta SOLO gli hook.
1623
+ //
1624
+ // Solo il client `claude` ha questi eventi. Gli altri motori (codex-vl, pi,
1625
+ // shell) restano senza canale: la UI li mostra «non verificati», che e' cio'
1626
+ // che si sa di loro. Non si inventa uno stato che nessuno pubblica.
1627
+
1628
+ // Gli eventi REGISTRATI, quelli misurati su 2.1.280 nel Gate A. `StopFailure`
1629
+ // non compare perche' su quella versione NON esiste: registrarlo sarebbe una
1630
+ // riga che non scatta mai.
1631
+ const EVENTI_ATTIVITA = Object.freeze([
1632
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
1633
+ 'SubagentStop', 'Stop', 'PermissionRequest', 'Notification', 'SessionEnd',
1634
+ ]);
1635
+
1636
+ // La root dei file per-cella. `cfg.filesRoot` c'e' quando la risoluzione arriva
1637
+ // dal server (che la porta nella propria cfg); alcuni chiamanti — i test in
1638
+ // isolamento — non la passano. In quel caso la si ricava dalla STESSA fonte di
1639
+ // configurazione invece di aggiungere un parametro che nessuno passerebbe:
1640
+ // `defaults()` e' baseDefaults + env (quindi onora NEXUSCREW_FILES_ROOT) e NON
1641
+ // legge il config.json del device, che e' esattamente cio' che serve a un
1642
+ // chiamante che non ha una cfg.
1643
+ function filesRootDi(cfg) {
1644
+ if (cfg && typeof cfg.filesRoot === 'string' && cfg.filesRoot) return cfg.filesRoot;
1645
+ try {
1646
+ const r = require('../config.js').defaults().filesRoot;
1647
+ return typeof r === 'string' && r ? r : null;
1648
+ } catch (_) { return null; }
1649
+ }
1650
+
1651
+ // Quoting POSIX: il comando di un hook e' una stringa che il client esegue con
1652
+ // la shell, e percorso dello script e directory di sessione possono contenere
1653
+ // spazi. Il singolo apice si rappresenta chiudendo, escapando e riaprendo —
1654
+ // l'unico modo che funziona anche dentro una stringa gia' quotata.
1655
+ function virgolette(valore) {
1656
+ return `'${String(valore).replace(/'/g, "'\\''")}'`;
1657
+ }
1658
+
1659
+ // Comando di UN hook. `process.execPath` e' il node che sta eseguendo il
1660
+ // server: e' lo stesso interprete che deve eseguire lo script, senza dipendere
1661
+ // dal PATH della cella.
1662
+ function comandoHook(evento, script, dirSessione, generazione) {
1663
+ const parti = [
1664
+ virgolette(process.execPath), virgolette(script),
1665
+ '--event', virgolette(evento), '--dir', virgolette(dirSessione),
1666
+ ];
1667
+ if (generazione) parti.push('--gen', virgolette(generazione));
1668
+ return parti.join(' ');
1669
+ }
1670
+
1671
+ // Oggetto `hooks` per la settings della cella, oppure null quando non e'
1672
+ // iniettabile (cella senza sessione tmux: senza nome non c'e' dove scrivere).
1673
+ function cellActivityHooks(cell, cfg) {
1674
+ // Opt-out esplicito. Serve a due cose: un operatore che non vuole hook nella
1675
+ // cella, e il test che confronta gli argomenti di lancio con e senza — un
1676
+ // confronto che gira sulla STESSA strada di codice, non su una copia.
1677
+ if (cfg && cfg.activityHooks === false) return null;
1678
+ if (!cell || typeof cell.tmuxSession !== 'string' || !cell.tmuxSession) return null;
1679
+ const root = filesRootDi(cfg);
1680
+ if (!root) return null;
1681
+ const script = path.join(__dirname, '..', '..', 'bin', 'nc-activity-hook.js');
1682
+ try { if (!fs.statSync(script).isFile()) return null; } catch (_) { return null; }
1683
+ const dirSessione = path.join(root, cell.tmuxSession);
1684
+ const generazione = cfg && typeof cfg.activityGeneration === 'string' && cfg.activityGeneration
1685
+ ? cfg.activityGeneration : null;
1686
+ const hooks = {};
1687
+ for (const evento of EVENTI_ATTIVITA) {
1688
+ const voce = { hooks: [{ type: 'command', command: comandoHook(evento, script, dirSessione, generazione) }] };
1689
+ // `matcher: '*'` SOLO sui due eventi tool: e' la forma MISURATA nel Gate A.
1690
+ // `Notification` viaggia senza matcher di proposito — filtrare per tipo
1691
+ // dalla settings significherebbe affidarsi a una semantica di matcher che
1692
+ // non e' stata misurata, e un matcher sbagliato spegnerebbe in silenzio
1693
+ // l'unico segnale di «attesa permesso». Il filtro per tipo lo fa lo script.
1694
+ if (evento === 'PreToolUse' || evento === 'PostToolUse') voce.matcher = '*';
1695
+ hooks[evento] = [voce];
1696
+ }
1697
+ return { hooks, dirSessione };
1698
+ }
1699
+
1700
+ // L'argomento `--settings` della cella: deny MCP e hook in UN SOLO oggetto.
1701
+ // Due sorgenti distinte sarebbero tecnicamente sicure per il deny (misurato:
1702
+ // con due `--settings` vince il deny), ma un oggetto solo e' cio' che si puo'
1703
+ // confrontare campo per campo con quello di prima — ed e' il confronto che
1704
+ // dimostra che l'unica chiave aggiunta e' `hooks`.
1705
+ function cellSettingsArgs(home, cell, attivita) {
1706
+ const deny = cellMcpDeny(home, cell);
1707
+ const out = {};
1708
+ if (deny && deny.length) out.permissions = { deny };
1709
+ if (attivita) out.hooks = attivita.hooks;
1710
+ if (!Object.keys(out).length) return [];
1711
+ return [`--settings=${JSON.stringify(out)}`];
1613
1712
  }
1614
1713
 
1615
1714
  // Definizioni MCP note, per NOME -> definizione (la prima sorgente che dichiara
@@ -1743,48 +1842,132 @@ function readCodexUserToml(codexHome) {
1743
1842
  return '';
1744
1843
  }
1745
1844
 
1845
+ // Superficie codex della config utente come TABELLE strutturate. Il parse e'
1846
+ // TOML vero (smol-toml), non la regex dei soli nomi di codexSurfaceNames: al
1847
+ // writer servono i blocchi completi dei server per copiare il trasporto, e
1848
+ // nomi quotati, sottotabelle e valori composti escono corretti. Una config
1849
+ // utente presente ma non parseabile e' un errore (fail-closed): si rifiuta il
1850
+ // profilo intero invece di emetterne uno che non ridurrebbe nulla.
1851
+ function codexSurfaceTables(configToml) {
1852
+ const testo = typeof configToml === 'string' ? configToml : '';
1853
+ if (!testo.trim()) return { servers: new Map(), skills: [] };
1854
+ let doc;
1855
+ try {
1856
+ doc = toml.parse(testo);
1857
+ } catch (e) {
1858
+ const err = new Error(`config utente non parseabile come TOML: ${e.message}`);
1859
+ err.code = 'CODEX_USER_TOML_INVALID';
1860
+ throw err;
1861
+ }
1862
+ const raw = doc.mcp_servers && typeof doc.mcp_servers === 'object' && !Array.isArray(doc.mcp_servers)
1863
+ ? doc.mcp_servers
1864
+ : {};
1865
+ const servers = new Map();
1866
+ for (const [nome, tabella] of Object.entries(raw)) {
1867
+ if (tabella && typeof tabella === 'object' && !Array.isArray(tabella)) servers.set(nome, tabella);
1868
+ }
1869
+ const voci = Array.isArray(doc.skills && doc.skills.config) ? doc.skills.config : [];
1870
+ const skills = [...new Set(voci
1871
+ .filter((s) => s && typeof s === 'object' && typeof s.name === 'string' && s.name)
1872
+ .map((s) => s.name))];
1873
+ return { servers, skills };
1874
+ }
1875
+
1876
+ // Rami di trasporto MCP e chiavi MINIME che soddisfano la validazione del
1877
+ // client: per stdio basta `command`, per streamable_http solo `url`. Per uno
1878
+ // SPENTO si copia solo questo: `args`, `env`, `http_headers`,
1879
+ // `bearer_token_env_var` e simili non servono a far passare il layer e sono
1880
+ // i campi che portano segreti (un token puo stare anche in `args`) — un file
1881
+ // derivato e rigenerato non deve duplicarli. Per un on-DEMAND si copia
1882
+ // invece la definizione INTEGRALE della tabella base: quel server e
1883
+ // concesso, il segreto gli compete, e la copia identica non puo introdurre
1884
+ // invalidita' nuove (la base valida resta valida).
1885
+ const TRASPORTO_MCP_STDIO = ['command'];
1886
+ const TRASPORTO_MCP_HTTP = ['url'];
1887
+
1888
+ function ramoTrasporto(tabella) {
1889
+ if (tabella && tabella.command !== undefined) return 'stdio';
1890
+ if (tabella && tabella.url !== undefined) return 'http';
1891
+ const err = new Error('server MCP senza trasporto riconoscibile (command/url) nella config utente');
1892
+ err.code = 'CODEX_MCP_TRANSPORT_UNKNOWN';
1893
+ throw err;
1894
+ }
1895
+
1896
+ function trasportoMinimo(tabella) {
1897
+ const campi = ramoTrasporto(tabella) === 'stdio' ? TRASPORTO_MCP_STDIO : TRASPORTO_MCP_HTTP;
1898
+ const out = {};
1899
+ for (const k of campi) {
1900
+ if (tabella[k] !== undefined) out[k] = tabella[k];
1901
+ }
1902
+ return out;
1903
+ }
1904
+
1746
1905
  function writeCellCodexProfile(cellId, declared, codexHome) {
1747
1906
  const nome = `nexuscrew-${String(cellId).replace(/\./g, '_')}`;
1748
1907
  const target = path.join(codexHome, `${nome}.config.toml`);
1749
1908
  const configToml = readCodexUserToml(codexHome);
1750
- const { servers, skills } = codexSurfaceNames(configToml);
1909
+ const { servers, skills } = codexSurfaceTables(configToml);
1751
1910
  const mcpConcessi = new Set(declared.mcp || []);
1752
1911
  const ondemand = new Set(declared.ondemand || []);
1753
1912
  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
- ];
1913
+ const nomiServer = [...servers.keys()].sort();
1914
+ const profilo = {};
1915
+ // OGNI tabella MCP emessa e' valida ANCHE DA SOLA: il write path
1916
+ // del client (config/batchWrite) valida il layer attivo PRIMA del documento
1917
+ // fuso (config_manager_service.rs: get_active_user_layer -> validate_config),
1918
+ // quindi una tabella ridotta senza trasporto romperebbe il salvataggio.
1919
+ // Copiare il trasporto non avvia gli spenti: restano `enabled = false`, e
1920
+ // il connection manager non li itera (connection_manager.rs). Spento =
1921
+ // minimo che soddisfa la validazione (mai segreti); on-demand = definizione
1922
+ // effettiva del layer base con la sola chiave di differimento sopra.
1758
1923
  if (declared.mcp !== undefined) {
1759
- for (const s of servers.filter((n) => !mcpConcessi.has(n)).sort()) {
1760
- righe.push('', `[mcp_servers.${s}]`, 'enabled = false');
1924
+ const tabelle = {};
1925
+ for (const s of nomiServer.filter((n) => !mcpConcessi.has(n))) {
1926
+ tabelle[s] = { ...trasportoMinimo(servers.get(s)), enabled: false };
1761
1927
  }
1928
+ if (Object.keys(tabelle).length) profilo.mcp_servers = tabelle;
1762
1929
  }
1763
1930
  // `omit_tools_from` NON e' una chiave di root: in codex e' un campo della
1764
1931
  // tabella del SINGOLO server (codex-rs/config/src/mcp_types.rs:223/:364) —
1765
1932
  // a root viene ignorato in silenzio e l'on-demand non funzionerebbe mai.
1766
1933
  // 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
1934
+ // la tabella riproduce la definizione del layer base (valida da sola) con
1935
+ // la sola chiave di differimento sopra. Uno spento (non concesso con
1768
1936
  // `mcp` dichiarata) non prende una seconda tabella: TOML vieta i duplicati
1769
1937
  // e enabled = false resta il volere piu' restrittivo. Nomi ondemand non
1770
1938
  // presenti nella config utente non generano tabelle (ignorati; la vista li
1771
1939
  // segnala).
1772
1940
  if (ondemand.size) {
1773
1941
  const spenti = declared.mcp !== undefined
1774
- ? new Set(servers.filter((n) => !mcpConcessi.has(n)))
1942
+ ? new Set(nomiServer.filter((n) => !mcpConcessi.has(n)))
1775
1943
  : 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"]');
1944
+ const tabelle = profilo.mcp_servers || (profilo.mcp_servers = {});
1945
+ for (const s of nomiServer.filter((n) => ondemand.has(n) && !spenti.has(n))) {
1946
+ // Anche l'on-demand deve essere valido da solo: la copia integrale del
1947
+ // layer base contiene il trasporto se il layer base e' valido; se non
1948
+ // ce l'ha, rifiuta invece di emettere una tabella invalida.
1949
+ ramoTrasporto(servers.get(s));
1950
+ tabelle[s] = { ...servers.get(s), omit_tools_from: ['direct'] };
1778
1951
  }
1952
+ if (!Object.keys(tabelle).length) delete profilo.mcp_servers;
1779
1953
  }
1780
1954
  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');
1955
+ const nonConcesse = skills.filter((n) => !skillConcesse.has(n));
1956
+ if (nonConcesse.length) {
1957
+ profilo.skills = { config: nonConcesse.map((name) => ({ name, enabled: false })) };
1783
1958
  }
1784
1959
  }
1960
+ const righe = [
1961
+ '# Generated by NexusCrew for this cell: reduces (never widens) the surface',
1962
+ '# declared in config.toml, which NexusCrew never modifies.',
1963
+ '# MCP transport fields are copied from config.toml: every table here is',
1964
+ '# valid on its own (the client validates this layer standalone on write).',
1965
+ ];
1966
+ const corpo = Object.keys(profilo).length ? toml.stringify(profilo).trimEnd() : '';
1967
+ const testo = corpo ? `${righe.join('\n')}\n\n${corpo}\n` : `${righe.join('\n')}\n`;
1785
1968
  const tmp = path.join(codexHome, `.${nome}.${crypto.randomBytes(6).toString('hex')}.tmp`);
1786
1969
  try {
1787
- fs.writeFileSync(tmp, `${righe.join('\n')}\n`, { mode: 0o600 });
1970
+ fs.writeFileSync(tmp, testo, { mode: 0o600 });
1788
1971
  fs.chmodSync(tmp, 0o600);
1789
1972
  fs.renameSync(tmp, target);
1790
1973
  } catch (e) {
@@ -2128,6 +2311,9 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2128
2311
  // I due profili Claude con configurazione privata: perdono gli MCP del file
2129
2312
  // principale e vanno ricollegati (vedi `sharedMcpArgs`).
2130
2313
  let privateProfile = false;
2314
+ // Directory di sessione degli hook di attivita': il launcher ci scrive la
2315
+ // generazione corrente. Null quando gli hook non sono iniettabili.
2316
+ let activityDir = null;
2131
2317
  // Effective permission policy: override PER-CELL PER-ENGINE (remembered) vince sul
2132
2318
  // default dell'engine. Mai si mutationa engine.managed.permissionPolicy (globale).
2133
2319
  // Pi resta sempre 'standard' (lo spec normalized rifiuta gia' unsafe per pi).
@@ -2279,8 +2465,10 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2279
2465
  // l'abbia chiesto.
2280
2466
  const { effectiveCapabilities } = require('./definitions.js');
2281
2467
  const eff = effectiveCapabilities(cell || {}, { capabilityProfiles: cfg.capabilityProfiles || null });
2468
+ const attivita = cellActivityHooks(cell, cfg);
2469
+ if (attivita) activityDir = attivita.dirSessione;
2282
2470
  if (eff.legacy || eff.declared.mcp === undefined) {
2283
- args.push(...cellMcpArgs(home, cell));
2471
+ args.push(...cellSettingsArgs(home, cell, attivita));
2284
2472
  } else {
2285
2473
  let file;
2286
2474
  try {
@@ -2300,6 +2488,12 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2300
2488
  return { ok: false, reason: motivo, mcpCellRefused: motivo };
2301
2489
  }
2302
2490
  args.push('--strict-mcp-config', `--mcp-config=${file}`);
2491
+ // Qui il `--settings` NON esisteva: nasce per gli hook e porta SOLO
2492
+ // quelli. Nessun `permissions`: in questo ramo la superficie la chiude
2493
+ // `--strict-mcp-config` con il file per cella, e aggiungere un deny
2494
+ // calcolato per enumerazione cambierebbe cio' che la cella vede — non e'
2495
+ // richiesto e non si fa.
2496
+ if (attivita) args.push(`--settings=${JSON.stringify({ hooks: attivita.hooks })}`);
2303
2497
  }
2304
2498
  } else if (spec.client === 'codex' || spec.client === 'codex-vl') {
2305
2499
  if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
@@ -2599,14 +2793,15 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2599
2793
  }
2600
2794
  }
2601
2795
  }
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');
2796
+ // Prompt su argv (0.8.47) SOLO per i client senza percorso classified
2797
+ // delivery. Dalla 0.9.41: TUTTE le claude.* passano a consegna
2798
+ // ritardata (kimi.* le avevano dal 0.8.47) — promptMode 'send-keys' con
2799
+ // deliverBootstrapPrompt (readiness classificata + at-most-once + gate
2800
+ // readiness MCP sui log cache del client): il bootstrap NON parte più
2801
+ // prima della handshake MCP e non compare in argv (visibile in ps / perso
2802
+ // dietro consenso/onboarding). vl non ha superficie prompt (file dedicato),
2803
+ // shell e' one-shot/login: nessun posizionale per loro per costruzione.
2804
+ const promptViaDelivery = spec.client === 'kimi' || spec.client === 'claude';
2610
2805
  // vl is a TUI without a prompt surface (`vl [OPTIONS]`, no prompt flag):
2611
2806
  // it NEVER receives the per-cell prompt on argv — it travels in the
2612
2807
  // per-cell file composed by the vl branch (VL_SYSTEM_APPEND_FILE).
@@ -2642,7 +2837,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2642
2837
  command = cfg.nodeExecPath || process.execPath;
2643
2838
  args.unshift(info.binary);
2644
2839
  }
2645
- return { ok: true, info, engine: {
2840
+ return { ok: true, info, activityDir, engine: {
2646
2841
  ...engine, command, args, env,
2647
2842
  promptMode: promptViaDelivery ? 'send-keys' : 'managed-argv', clientBinary: info.binary,
2648
2843
  // 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
+ };
@@ -190,8 +190,9 @@ async function deliverBootstrapPrompt(opts = {}) {
190
190
  const readyWaitMs = clampInt(opts.readyWaitMs, 15000, 0, 120000);
191
191
  const pollMs = clampInt(opts.pollMs, 400, 50, 5000);
192
192
  const settleMs = clampInt(opts.settleMs, 150, 0, 5000);
193
- const done = (delivered, state, notReady, attempts) => ({
193
+ const done = (delivered, state, notReady, attempts, mcp) => ({
194
194
  delivered, state, notReady, attempts, reason: state,
195
+ ...(mcp ? { mcp } : {}),
195
196
  });
196
197
  if (!promptCharsOk(prompt)) return done(false, 'prompt-rejected', '', 0);
197
198
 
@@ -217,6 +218,25 @@ async function deliverBootstrapPrompt(opts = {}) {
217
218
  await sleepImpl(pollMs);
218
219
  }
219
220
 
221
+ // (1b) Gate readiness MCP opzionale (solo claude.*): la mcpWait e'
222
+ // fornita dal supervisore, con deadline ASSOLUTA composta sullo stesso
223
+ // orologio del launch (tetto MCP dal budget approvato, mai un budget nuovo a
224
+ // valle). Il composer pronto resta SEMPRE obbligatorio: qui si arriva solo
225
+ // col pane ready. A budget esaurito senza MCP pronti la consegna prosegue
226
+ // come AVVIO DEGRADATO VISIBILE: esito submitted/degraded con l'elenco
227
+ // bounded dei server failed/pending, mai un lancio silenzioso.
228
+ let mcp = null;
229
+ if (typeof opts.mcpWait === 'function') {
230
+ const r = await opts.mcpWait({ isCancelled: cancelled });
231
+ if (cancelled() || !r || r.cancelled) return done(false, 'cancelled', '', 0);
232
+ mcp = {
233
+ state: r.state === 'ready' ? 'ready' : 'degraded',
234
+ ready: Array.isArray(r.ready) ? r.ready : [],
235
+ failed: Array.isArray(r.failed) ? r.failed : [],
236
+ pending: Array.isArray(r.pending) ? r.pending : [],
237
+ };
238
+ }
239
+
220
240
  // (2) Consegna AT-MOST-ONCE: un solo paste per generazione. Il retry copre
221
241
  // SOLO fallimenti certi pre-paste (resolve/load); dopo qualsiasi tentativo
222
242
  // Di paste-buffer non esiste retry automatico.
@@ -239,32 +259,54 @@ async function deliverBootstrapPrompt(opts = {}) {
239
259
  if (paneAgain !== stage.paneId) return done(false, 'delivery-unknown', '', attempts);
240
260
  if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
241
261
  const enter = await exec(tmuxBin, ['send-keys', '-t', stage.paneId, 'Enter'], { env });
242
- if (enter.err) return done(false, 'staged-not-submitted', '', attempts);
262
+ if (enter.err) return done(false, 'staged-not-submitted', '', attempts, mcp);
243
263
  // Cancel con Enter appena partito -> la generazione e' morta con un
244
264
  // submit in volo: esito incerto (residuo PTY possibile), mai 'submitted'.
245
265
  if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
246
- return done(true, 'submitted', '', attempts);
266
+ return done(true, 'submitted', '', attempts, mcp);
247
267
  }
248
268
  if (stage.stage === 'paste' || stage.stage === 'validate') {
249
269
  // Paste tentato (esito incerto) o input rifiutato: zero secondo paste.
250
- return done(false, stage.stage === 'validate' ? 'prompt-rejected' : 'delivery-unknown', '', attempts);
270
+ return done(false, stage.stage === 'validate' ? 'prompt-rejected' : 'delivery-unknown', '', attempts, mcp);
251
271
  }
252
- if (attempts >= 2) return done(false, 'failed-pre-paste', '', attempts);
272
+ if (attempts >= 2) return done(false, 'failed-pre-paste', '', attempts, mcp);
253
273
  // Fallimento certo PRE-paste: un solo retry, readiness riverificata prima.
254
274
  await sleepImpl(pollMs);
255
275
  if (cancelled()) return done(false, 'cancelled', '', attempts);
256
276
  const text = await capture();
257
277
  const again = text === null ? 'unknown' : classifyPane(text, client);
258
- if (again !== 'ready') return done(false, 'failed-pre-paste', '', attempts);
278
+ if (again !== 'ready') return done(false, 'failed-pre-paste', '', attempts, mcp);
259
279
  }
260
280
  }
261
281
 
262
282
  // WaitDeliveryReport(tmuxBin, target, opts) -> delivery-like null.
263
283
  // Il launcher supervisionato (cell-exec) e' l'UNICO owner della consegna per
264
- // TUTTE le generazioni degli engine Kimi e pubblica l'esito bounded sull'
265
- // opzione tmux di pane @nc_delivery ('<state>' o '<state>:<notReady>', solo
266
- // enum chiusi; muore col pane, nessuno state file). up() la legge con attesa
267
- // bounded: niente paste dal runtime, niente doppio writer cross-generation.
284
+ // TUTTE le generazioni degli engine Kimi e claude.* e pubblica l'esito bounded
285
+ // sull'opzione tmux di pane @nc_delivery ('<state>' / '<state>:<notReady>' /
286
+ // '<state>:<notReady>:mcp<state>' dal 0.9.41, solo enum chiusi; muore col
287
+ // pane, nessuno state file). Con degrado MCP l'elenco bounded dei server
288
+ // failed/pending viaggia su @nc_mcp_list ('failed=a,b|pending=c', nomi
289
+ // validati). up() la legge con attesa bounded: niente paste dal runtime,
290
+ // niente doppio writer cross-generation.
291
+ const MCP_REPORT_STATES = Object.freeze(['ready', 'degraded']);
292
+ const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
293
+
294
+ function parseMcpListOption(raw) {
295
+ const out = { failed: [], pending: [] };
296
+ const text = typeof raw === 'string' ? raw.trim() : '';
297
+ if (!text) return out;
298
+ for (const part of text.split('|')) {
299
+ const eq = part.indexOf('=');
300
+ if (eq <= 0) continue;
301
+ const kind = part.slice(0, eq);
302
+ if (kind !== 'failed' && kind !== 'pending') continue;
303
+ for (const nome of part.slice(eq + 1).split(',')) {
304
+ if (nome && MCP_NAME_RE.test(nome) && !out[kind].includes(nome)) out[kind].push(nome);
305
+ }
306
+ }
307
+ return out;
308
+ }
309
+
268
310
  async function waitDeliveryReport(tmuxBin, target, { env, exec, sleepImpl, nowImpl, timeoutMs, pollMs } = {}) {
269
311
  const run = exec || tmuxExec;
270
312
  const sleepFn = sleepImpl || sleep;
@@ -277,13 +319,24 @@ async function waitDeliveryReport(tmuxBin, target, { env, exec, sleepImpl, nowIm
277
319
  { env, timeoutMs: 2000 });
278
320
  const raw = r.err ? '' : r.stdout.trim();
279
321
  if (raw) {
280
- const [state, notReady = ''] = raw.split(':');
322
+ const [state, notReady = '', mcpTag = ''] = raw.split(':');
281
323
  if (!DELIVERY_STATES.includes(state)) return null; // valore non bounded: mai fidarsi
282
324
  const kind = PANE_STATES.includes(notReady) ? notReady : '';
325
+ const mcpState = mcpTag.startsWith('mcp') && MCP_REPORT_STATES.includes(mcpTag.slice(3))
326
+ ? mcpTag.slice(3) : '';
327
+ let mcp = null;
328
+ if (mcpState) {
329
+ const r2 = await run(tmuxBin,
330
+ ['display-message', '-p', '-t', target, '#{@nc_mcp_list}'],
331
+ { env, timeoutMs: 2000 });
332
+ const { failed, pending } = parseMcpListOption(r2.err ? '' : r2.stdout);
333
+ mcp = { state: mcpState, ready: [], failed, pending };
334
+ }
283
335
  return {
284
336
  delivered: state === 'submitted',
285
337
  state, notReady: state === 'skipped-not-ready' ? kind : '',
286
338
  attempts: 0, reason: state,
339
+ ...(mcp ? { mcp } : {}),
287
340
  };
288
341
  }
289
342
  if (now() >= deadline) return null;
@@ -318,6 +371,6 @@ function actionRequiredFor(client, provider, delivery) {
318
371
  }
319
372
 
320
373
  module.exports = {
321
- PANE_STATES, DELIVERY_STATES, ACTION_CODES, RECOVERY_SLUGS,
374
+ PANE_STATES, DELIVERY_STATES, ACTION_CODES, RECOVERY_SLUGS, MCP_REPORT_STATES, parseMcpListOption,
322
375
  classifyPane, deliverBootstrapPrompt, waitDeliveryReport, actionRequiredFor, vlPaneReadiness,
323
376
  };