@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.
- package/CHANGELOG.md +8 -1
- package/bin/nc-activity-hook.js +55 -0
- package/frontend/dist/assets/{index-Csz8VaFz.css → index-Ca1Rl40n.css} +1 -1
- package/frontend/dist/assets/index-DGa2FEN2.js +169 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/files/activity.js +294 -0
- package/lib/fleet/cell-exec.js +66 -3
- package/lib/fleet/managed.js +225 -30
- package/lib/fleet/mcp-cache-readiness.js +154 -0
- package/lib/fleet/prompt-delivery.js +65 -12
- package/lib/fleet/runtime.js +95 -9
- package/lib/notify/ask-relay.js +104 -19
- package/lib/server.js +13 -0
- package/package.json +4 -3
- package/frontend/dist/assets/index-BJBurY16.js +0 -169
package/lib/fleet/managed.js
CHANGED
|
@@ -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
|
|
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 [
|
|
1607
|
+
if (!voluti) return null;
|
|
1608
|
+
if (!voluti.length) return ['mcp__*'];
|
|
1608
1609
|
const concessi = new Set(voluti);
|
|
1609
|
-
|
|
1610
|
+
return [...knownMcpServerNames(home, cell.cwd)]
|
|
1610
1611
|
.filter((nome) => !concessi.has(nome)).sort().map((nome) => `mcp__${nome}`);
|
|
1611
|
-
|
|
1612
|
-
|
|
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 } =
|
|
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
|
|
1755
|
-
|
|
1756
|
-
|
|
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
|
-
|
|
1760
|
-
|
|
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
|
-
//
|
|
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(
|
|
1942
|
+
? new Set(nomiServer.filter((n) => !mcpConcessi.has(n)))
|
|
1775
1943
|
: new Set();
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
-
|
|
1782
|
-
|
|
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,
|
|
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(...
|
|
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)
|
|
2603
|
-
// delivery.
|
|
2604
|
-
//
|
|
2605
|
-
//
|
|
2606
|
-
//
|
|
2607
|
-
// (
|
|
2608
|
-
|
|
2609
|
-
|
|
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
|
|
265
|
-
// opzione tmux di pane @nc_delivery ('<state>'
|
|
266
|
-
// enum chiusi; muore col
|
|
267
|
-
//
|
|
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
|
};
|