@mmmbuto/nexuscrew 0.9.41 → 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.
- package/CHANGELOG.md +13 -0
- package/frontend/dist/assets/index-CPO7bv6-.css +32 -0
- package/frontend/dist/assets/index-DpFbq_tt.js +169 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/files/activity.js +136 -23
- package/lib/fleet/ai-desktop.js +123 -0
- package/lib/fleet/cell-exec.js +86 -1
- package/lib/fleet/codex-hooks.js +253 -0
- package/lib/fleet/managed.js +53 -4
- package/lib/fleet/runtime.js +69 -7
- package/lib/proxy/panel-proxy.js +16 -2
- package/lib/server.js +30 -3
- package/lib/settings/routes.js +55 -3
- package/lib/tmux/list.js +19 -1
- package/lib/tmux/supervisor-pane.js +81 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-Ca1Rl40n.css +0 -32
- package/frontend/dist/assets/index-DGa2FEN2.js +0 -169
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Canale di attivita' per le celle codex e codex-vl.
|
|
3
|
+
//
|
|
4
|
+
// Perche' esiste: le celle Claude pubblicano il proprio stato di turno con gli
|
|
5
|
+
// hook di Claude Code (bin/nc-activity-hook.js, iniettato via --settings). Gli
|
|
6
|
+
// engine codex non hanno quel meccanismo, quindi fino a oggi la UI le mostrava
|
|
7
|
+
// «non verificate» — che era la verita', ma anche un'informazione che si poteva
|
|
8
|
+
// avere. Codex ha un sistema di hook proprio, con una differenza che conta:
|
|
9
|
+
// un hook iniettato da riga di comando NON e' fidato finche' il suo hash non
|
|
10
|
+
// compare in `hooks.state`, altrimenti il client apre un dialogo di revisione e
|
|
11
|
+
// non lo esegue.
|
|
12
|
+
//
|
|
13
|
+
// Questo modulo costruisce i due pezzi che servono, e nient'altro:
|
|
14
|
+
// 1. gli argomenti `-c hooks.<Evento>=[...]` che DEFINISCONO l'hook;
|
|
15
|
+
// 2. gli argomenti `-c hooks.state={...}` che lo FIDANO, con l'hash che il
|
|
16
|
+
// client si aspetta di trovare.
|
|
17
|
+
//
|
|
18
|
+
// L'hash non e' lo sha256 del testo del comando: e' lo sha256 del JSON CANONICO
|
|
19
|
+
// di un'identita' normalizzata (evento + handler normalizzato). Replicato da
|
|
20
|
+
// codex-rs/hooks/src/engine/discovery.rs (`hook_hash`) e
|
|
21
|
+
// codex-rs/config/src/fingerprint.rs (`version_for_toml`).
|
|
22
|
+
//
|
|
23
|
+
// La forma `-c hooks.state.<chiave>.trusted_hash=...` NON funziona: il parser
|
|
24
|
+
// di `-c` spezza il path sui punti (codex-rs/config/src/overrides.rs) e la
|
|
25
|
+
// chiave dell'hook ne contiene. Percio' `hooks.state` si passa come TABELLA
|
|
26
|
+
// inline. Verificato in una prova isolata su TUI reale, su entrambe le
|
|
27
|
+
// versioni in uso.
|
|
28
|
+
|
|
29
|
+
const crypto = require('node:crypto');
|
|
30
|
+
const { termuxRuntimePaths } = require('../runtime/env.js');
|
|
31
|
+
|
|
32
|
+
// Etichette usate nelle chiavi di stato — codex-rs/hooks/src/lib.rs,
|
|
33
|
+
// `hook_event_key_label`. Solo gli eventi che iniettiamo.
|
|
34
|
+
const ETICHETTA_EVENTO = Object.freeze({
|
|
35
|
+
PreToolUse: 'pre_tool_use',
|
|
36
|
+
PostToolUse: 'post_tool_use',
|
|
37
|
+
SessionStart: 'session_start',
|
|
38
|
+
SessionEnd: 'session_end',
|
|
39
|
+
UserPromptSubmit: 'user_prompt_submit',
|
|
40
|
+
Stop: 'stop',
|
|
41
|
+
Interrupt: 'interrupt',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Timeout normalizzato — codex-rs/hooks/src/engine/discovery.rs,
|
|
45
|
+
// `normalize_command_hook`: dieci minuti per tutti, UN SECONDO per SessionEnd e
|
|
46
|
+
// Interrupt (che hanno anche un tetto di tre). Il valore entra nell'hash,
|
|
47
|
+
// quindi sbagliarlo qui farebbe ricomparire il dialogo di revisione.
|
|
48
|
+
const TIMEOUT_STANDARD_SEC = 600;
|
|
49
|
+
const TIMEOUT_FINE_SEC = 1;
|
|
50
|
+
|
|
51
|
+
// La fonte sintetica da cui il client crede che l'hook provenga: e' il layer
|
|
52
|
+
// `SessionFlags`, cioe' proprio cio' che passiamo con `-c`
|
|
53
|
+
// (discovery.rs:402-422).
|
|
54
|
+
const KEY_SOURCE_SESSION_FLAGS = '/<session-flags>/config.toml';
|
|
55
|
+
|
|
56
|
+
function etichetta(evento) {
|
|
57
|
+
const label = ETICHETTA_EVENTO[evento];
|
|
58
|
+
if (!label) throw new Error(`evento hook non gestito: ${evento}`);
|
|
59
|
+
return label;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function timeoutPerEvento(evento) {
|
|
63
|
+
return evento === 'SessionEnd' || evento === 'Interrupt'
|
|
64
|
+
? TIMEOUT_FINE_SEC
|
|
65
|
+
: TIMEOUT_STANDARD_SEC;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Riordina le chiavi in modo ricorsivo: `version_for_toml` serializza il JSON
|
|
69
|
+
// CANONICO, e due identita' con le stesse chiavi in ordine diverso devono dare
|
|
70
|
+
// lo stesso hash.
|
|
71
|
+
function ordinaChiavi(valore) {
|
|
72
|
+
if (Array.isArray(valore)) return valore.map(ordinaChiavi);
|
|
73
|
+
if (valore && typeof valore === 'object') {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const chiave of Object.keys(valore).sort()) out[chiave] = ordinaChiavi(valore[chiave]);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
return valore;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function jsonCanonico(valore) {
|
|
82
|
+
return JSON.stringify(ordinaChiavi(valore));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Hash atteso da codex per questo handler su questo evento.
|
|
87
|
+
* Replica `hook_hash` -> `version_for_toml`.
|
|
88
|
+
*/
|
|
89
|
+
function hashHook(evento, comando) {
|
|
90
|
+
const identita = {
|
|
91
|
+
event_name: etichetta(evento),
|
|
92
|
+
hooks: [{
|
|
93
|
+
async: false,
|
|
94
|
+
command: comando,
|
|
95
|
+
timeout: timeoutPerEvento(evento),
|
|
96
|
+
type: 'command',
|
|
97
|
+
}],
|
|
98
|
+
};
|
|
99
|
+
const digest = crypto.createHash('sha256').update(jsonCanonico(identita), 'utf8').digest('hex');
|
|
100
|
+
return `sha256:${digest}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Chiave dello stato per un handler: `hook_key` (hooks/src/lib.rs). */
|
|
104
|
+
function chiaveHook(evento, gruppo = 0, handler = 0) {
|
|
105
|
+
return `${KEY_SOURCE_SESSION_FLAGS}:${etichetta(evento)}:${gruppo}:${handler}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Stringa TOML. I valori qui sono percorsi e comandi unix, senza backslash:
|
|
109
|
+
// JSON.stringify produce una basic string corretta per questi contenuti.
|
|
110
|
+
function tomlString(valore) {
|
|
111
|
+
return JSON.stringify(String(valore));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Definizione di UN hook: `hooks.<Evento>=[{hooks=[{type,command}]}]`. */
|
|
115
|
+
function definizioneHook(evento, comando) {
|
|
116
|
+
return `hooks.${evento}=[{hooks=[{type="command",command=${tomlString(comando)}}]}]`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `hooks.state` come tabella inline con un trusted_hash per chiave. */
|
|
120
|
+
function tabellaStato(voci) {
|
|
121
|
+
const parti = voci.map(({ chiave, hash }) => `${tomlString(chiave)}={trusted_hash=${tomlString(hash)}}`);
|
|
122
|
+
return `hooks.state={${parti.join(',')}}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Argomenti da appendere alla riga di lancio di una cella codex/codex-vl.
|
|
127
|
+
* `comandoPerEvento(evento)` ritorna il comando dell'hook per quell'evento.
|
|
128
|
+
* Ritorna [] se non c'e' niente da iniettare.
|
|
129
|
+
*/
|
|
130
|
+
function argomentiHookCodex(eventi, comandoPerEvento) {
|
|
131
|
+
if (!Array.isArray(eventi) || eventi.length === 0) return [];
|
|
132
|
+
const args = [];
|
|
133
|
+
const voci = [];
|
|
134
|
+
for (const evento of eventi) {
|
|
135
|
+
const comando = comandoPerEvento(evento);
|
|
136
|
+
if (!comando) continue;
|
|
137
|
+
args.push('-c', definizioneHook(evento, comando));
|
|
138
|
+
voci.push({ chiave: chiaveHook(evento), hash: hashHook(evento, comando) });
|
|
139
|
+
}
|
|
140
|
+
if (voci.length === 0) return [];
|
|
141
|
+
args.push('-c', tabellaStato(voci));
|
|
142
|
+
return args;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Guardia di versione ────────────────────────────────────────────────
|
|
146
|
+
//
|
|
147
|
+
// L'hash dipende dalla normalizzazione, dalla serializzazione e dal source
|
|
148
|
+
// path sintetico di UNA versione di codex. Se il binario cambia quelle regole,
|
|
149
|
+
// l'hash non combacia piu', il client apre il dialogo «Hooks need review» e la
|
|
150
|
+
// cella resta BLOCCATA su una domanda che nessuno vede. Il costo di sbagliare
|
|
151
|
+
// non e' «hook che non parte»: e' una cella che non lavora.
|
|
152
|
+
//
|
|
153
|
+
// Quindi si inietta SOLO su versioni effettivamente provate, e su ogni altra
|
|
154
|
+
// non si inietta niente: la cella torna «non verificato» come oggi, che e' il
|
|
155
|
+
// comportamento di prima — un degrado dichiarato, non un guasto introdotto.
|
|
156
|
+
const VERSIONI_PROVATE = Object.freeze({
|
|
157
|
+
codex: ['0.156.1'],
|
|
158
|
+
'codex-vl': ['0.155.1'],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Probe della versione, con lo stesso seam del ramo vl (cfg.vlVersionProbe):
|
|
162
|
+
// i test non devono dipendere da un binario vero. Ritorna l'output GREZZO.
|
|
163
|
+
// `binary` e' il percorso RISOLTO del binario della cella, non il nome del
|
|
164
|
+
// client: si esegue quello, senza shell e con un tetto di tempo breve.
|
|
165
|
+
function versionOutput(binary, cfg) {
|
|
166
|
+
const probe = cfg && typeof cfg.codexVersionProbe === 'function' ? cfg.codexVersionProbe : null;
|
|
167
|
+
if (probe) {
|
|
168
|
+
const out = probe(binary);
|
|
169
|
+
return out === null || out === undefined ? null : String(out);
|
|
170
|
+
}
|
|
171
|
+
const { spawnSync } = require('node:child_process');
|
|
172
|
+
const r = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: 3000, shell: false });
|
|
173
|
+
if (r.error || r.status !== 0) return null;
|
|
174
|
+
return `${r.stdout || ''}${r.stderr || ''}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Si puo' iniettare su questo binario? Ritorna { ok, versione } oppure
|
|
179
|
+
* { ok: false, reason } — il motivo e' per la riga di log, mai per l'utente.
|
|
180
|
+
* `client` sceglie l'elenco delle versioni provate; `binary` e' cio' che si
|
|
181
|
+
* esegue davvero. Sono due cose diverse: se coincidono e' una coincidenza.
|
|
182
|
+
*/
|
|
183
|
+
function gateVersione(client, binary, cfg) {
|
|
184
|
+
const ammesse = VERSIONI_PROVATE[client];
|
|
185
|
+
if (!ammesse) return { ok: false, reason: `client ${client} non gestito dal canale hook` };
|
|
186
|
+
if (!binary) {
|
|
187
|
+
return { ok: false, reason: `binario di ${client} non risolto sul nodo: nessun hook iniettato` };
|
|
188
|
+
}
|
|
189
|
+
const out = versionOutput(binary, cfg);
|
|
190
|
+
if (out === null) {
|
|
191
|
+
return { ok: false, reason: `versione di ${client} non determinabile (${binary} --version non risponde): nessun hook iniettato` };
|
|
192
|
+
}
|
|
193
|
+
const m = out.match(/(\d+\.\d+\.\d+)/);
|
|
194
|
+
if (!m) {
|
|
195
|
+
return { ok: false, reason: `versione di ${client} non riconosciuta da "${String(out).trim().slice(0, 60)}": nessun hook iniettato` };
|
|
196
|
+
}
|
|
197
|
+
if (!ammesse.includes(m[1])) {
|
|
198
|
+
return { ok: false, reason: `versione ${client} ${m[1]} non provata con gli hook (provate: ${ammesse.join(', ')}): nessun hook iniettato` };
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, versione: m[1] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// La chiave di fiducia di questi hook e' Unix: `key_source` e'
|
|
204
|
+
// `/<session-flags>/config.toml` e il comando e' quotato in stile POSIX. Il
|
|
205
|
+
// sorgente di codex sintetizza quella fonte con `C:\` su Windows
|
|
206
|
+
// (`hooks/src/engine/discovery.rs:402-435`), quindi la' la chiave che il
|
|
207
|
+
// client calcola NON coincide con quella che generiamo: si inietterebbe un
|
|
208
|
+
// hook non fidato, cioe' esattamente il dialogo che la guardia esiste per
|
|
209
|
+
// evitare. Finche' non c'e' una prova su Windows, li' non si inietta.
|
|
210
|
+
//
|
|
211
|
+
// Termux e' un caso diverso e piu' stretto: il probe esegue il binario
|
|
212
|
+
// direttamente, mentre il launcher lo lancia tramite Node quando lo shim ha
|
|
213
|
+
// uno shebang senza `/usr/bin/env` (`managed.js:1006-1028`). Il gate
|
|
214
|
+
// fallirebbe chiuso, e una cella «non verificata» senza motivo e' un limite
|
|
215
|
+
// che si dichiara qui invece di lasciarlo scoprire.
|
|
216
|
+
function gatePiattaforma(cfg) {
|
|
217
|
+
const platform = (cfg && cfg.platform) || process.platform;
|
|
218
|
+
const env = (cfg && cfg.env) || process.env;
|
|
219
|
+
const termux = platform === 'android'
|
|
220
|
+
|| termuxRuntimePaths(env, { platform, home: cfg && cfg.home }) !== null;
|
|
221
|
+
if (termux) {
|
|
222
|
+
return { ok: false, reason: 'termux: il probe della versione non segue il percorso di lancio' };
|
|
223
|
+
}
|
|
224
|
+
if (platform !== 'linux' && platform !== 'darwin') {
|
|
225
|
+
return { ok: false, reason: `piattaforma ${platform}: chiave di fiducia e quoting degli hook non provati qui` };
|
|
226
|
+
}
|
|
227
|
+
return { ok: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Eventi iniettati per una cella codex, nell'ordine in cui il client li emette.
|
|
231
|
+
const EVENTI_CODEX = Object.freeze([
|
|
232
|
+
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt', 'SessionEnd',
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
module.exports = {
|
|
236
|
+
ETICHETTA_EVENTO,
|
|
237
|
+
TIMEOUT_STANDARD_SEC,
|
|
238
|
+
TIMEOUT_FINE_SEC,
|
|
239
|
+
KEY_SOURCE_SESSION_FLAGS,
|
|
240
|
+
VERSIONI_PROVATE,
|
|
241
|
+
EVENTI_CODEX,
|
|
242
|
+
etichetta,
|
|
243
|
+
timeoutPerEvento,
|
|
244
|
+
jsonCanonico,
|
|
245
|
+
hashHook,
|
|
246
|
+
chiaveHook,
|
|
247
|
+
definizioneHook,
|
|
248
|
+
tabellaStato,
|
|
249
|
+
argomentiHookCodex,
|
|
250
|
+
versionOutput,
|
|
251
|
+
gateVersione,
|
|
252
|
+
gatePiattaforma,
|
|
253
|
+
};
|
package/lib/fleet/managed.js
CHANGED
|
@@ -10,6 +10,7 @@ const { execFile } = require('node:child_process');
|
|
|
10
10
|
const { ENV_KEY_RE } = require('./env-key.js');
|
|
11
11
|
const { termuxRuntimePaths } = require('../runtime/env.js');
|
|
12
12
|
const { readCredentialStore, safePrivateDir } = require('./credentials.js');
|
|
13
|
+
const { argomentiHookCodex, gateVersione, gatePiattaforma, EVENTI_CODEX } = require('./codex-hooks.js');
|
|
13
14
|
|
|
14
15
|
const OLLAMA_CLOUD_MODELS = Object.freeze([
|
|
15
16
|
'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
|
|
@@ -1621,9 +1622,13 @@ function cellMcpDeny(home, cell) {
|
|
|
1621
1622
|
// un solo token composto, non due sorgenti distinte. Dove quel token oggi non
|
|
1622
1623
|
// esiste (ramo strict) ne nasce uno che porta SOLO gli hook.
|
|
1623
1624
|
//
|
|
1624
|
-
//
|
|
1625
|
-
//
|
|
1626
|
-
//
|
|
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.
|
|
1627
1632
|
|
|
1628
1633
|
// Gli eventi REGISTRATI, quelli misurati su 2.1.280 nel Gate A. `StopFailure`
|
|
1629
1634
|
// non compare perche' su quella versione NON esiste: registrarlo sarebbe una
|
|
@@ -1683,6 +1688,14 @@ function cellActivityHooks(cell, cfg) {
|
|
|
1683
1688
|
const dirSessione = path.join(root, cell.tmuxSession);
|
|
1684
1689
|
const generazione = cfg && typeof cfg.activityGeneration === 'string' && cfg.activityGeneration
|
|
1685
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.
|
|
1686
1699
|
const hooks = {};
|
|
1687
1700
|
for (const evento of EVENTI_ATTIVITA) {
|
|
1688
1701
|
const voce = { hooks: [{ type: 'command', command: comandoHook(evento, script, dirSessione, generazione) }] };
|
|
@@ -1694,7 +1707,9 @@ function cellActivityHooks(cell, cfg) {
|
|
|
1694
1707
|
if (evento === 'PreToolUse' || evento === 'PostToolUse') voce.matcher = '*';
|
|
1695
1708
|
hooks[evento] = [voce];
|
|
1696
1709
|
}
|
|
1697
|
-
|
|
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 };
|
|
1698
1713
|
}
|
|
1699
1714
|
|
|
1700
1715
|
// L'argomento `--settings` della cella: deny MCP e hook in UN SOLO oggetto.
|
|
@@ -2496,6 +2511,40 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
2496
2511
|
if (attivita) args.push(`--settings=${JSON.stringify({ hooks: attivita.hooks })}`);
|
|
2497
2512
|
}
|
|
2498
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
|
+
}
|
|
2499
2548
|
if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
|
|
2500
2549
|
else if (spec.provider === 'openai-api') env.OPENAI_API_KEY = cred.value;
|
|
2501
2550
|
else if (spec.provider === 'ollama-cloud') {
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -17,11 +17,13 @@ const crypto = require('node:crypto');
|
|
|
17
17
|
const {
|
|
18
18
|
loadDefinitions, validateCommandTrust, resolveCwd,
|
|
19
19
|
} = require('./definitions.js');
|
|
20
|
+
const aiDesktop = require('./ai-desktop.js');
|
|
20
21
|
const {
|
|
21
22
|
describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels, extraModelsFrom,
|
|
22
23
|
knownMcpServerNames,
|
|
23
24
|
} = require('./managed.js');
|
|
24
25
|
const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
|
|
26
|
+
const { NC_SUPERVISOR_OPT } = require('../tmux/supervisor-pane.js');
|
|
25
27
|
const {
|
|
26
28
|
httpError, minimalEnv, tmuxExec,
|
|
27
29
|
composeClientInvocation, alternateScreenArgs,
|
|
@@ -29,7 +31,6 @@ const {
|
|
|
29
31
|
redactSecrets, sanitizeEarlyDiagnostic,
|
|
30
32
|
} = require('./launch.js');
|
|
31
33
|
const { waitDeliveryReport, actionRequiredFor, vlPaneReadiness } = require('./prompt-delivery.js');
|
|
32
|
-
const { scriviGenerazione } = require('../files/activity.js');
|
|
33
34
|
|
|
34
35
|
// Insieme MCP MATERIALIZZATO della cella — le chiavi del file che
|
|
35
36
|
// writeCellMcpConfig (managed.js) scrive PRIMA dello spawn. Un server non
|
|
@@ -178,7 +179,12 @@ function createBuiltinRuntime(ctx) {
|
|
|
178
179
|
// engineDef gia' usata sopra per la permission policy: un valore
|
|
179
180
|
// presente qui e' gia' passato da validPanelUrl a monte (parseEngine/
|
|
180
181
|
// parseCell), quindi si copia, non si ri-valida.
|
|
181
|
-
|
|
182
|
+
// La spunta del desktop grafico taglia qui il pannello del container
|
|
183
|
+
// quando è esplicitamente OFF: il campo non esce dallo stato, quindi il
|
|
184
|
+
// tasto e la sorgente Pannello spariscono da OGNI superficie senza un
|
|
185
|
+
// if nel frontend. Solo la chiave esplicita comanda: qui non si
|
|
186
|
+
// interroga mai docker (percorso caldo a polling).
|
|
187
|
+
const panelUrl = aiDesktop.pubblicaPanelUrlCaldo(c.panelUrl || engineDef?.panelUrl || '');
|
|
182
188
|
return {
|
|
183
189
|
// `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
|
|
184
190
|
// leggibile e viaggia accanto, senza mai sostituirlo.
|
|
@@ -290,7 +296,32 @@ function createBuiltinRuntime(ctx) {
|
|
|
290
296
|
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
291
297
|
const engine = findEngine(defs, cell.engine);
|
|
292
298
|
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
299
|
+
// (0) Una cella GIA' VIVA non si rilancia — e soprattutto non si risolve.
|
|
300
|
+
// Senza questo controllo l'ordine era: si risolve lo spec (che PUBBLICA la
|
|
301
|
+
// generazione di attivita', managed.js:1696), si chiede il ticket, e solo
|
|
302
|
+
// alla new-session si scopre che la sessione esiste gia' -> 409. Il file
|
|
303
|
+
// `activity.gen` restava pero' riscritto con una generazione nuova, mentre
|
|
304
|
+
// gli hook del lancio vero portano quella vecchia: lo stato di quella cella
|
|
305
|
+
// veniva scartato (files/activity.js:336-338) e la UI diceva «non
|
|
306
|
+
// verificata» su una cella che stava lavorando. Lo pagava qualunque `up` su
|
|
307
|
+
// una cella viva: l'operatore dalla UI, e `fleet-boot` all'avvio del
|
|
308
|
+
// servizio, che cicla TUTTE le celle boot:true e su quelle gia' in
|
|
309
|
+
// esecuzione si limitava a saltare (boot.js:49) — dopo aver riscritto il
|
|
310
|
+
// file.
|
|
311
|
+
// Il 409 in fondo (new-session) resta come rete per la corsa fra questo
|
|
312
|
+
// controllo e la creazione della sessione.
|
|
313
|
+
const vive = await refreshSessions();
|
|
314
|
+
if (vive.has(String(cell.tmuxSession))) {
|
|
315
|
+
throw httpError(409, 'sessione già in esecuzione', null,
|
|
316
|
+
{ phase: 'preflight', code: 'SESSION_DUPLICATE' });
|
|
317
|
+
}
|
|
293
318
|
let launchEngine = engine;
|
|
319
|
+
// Canale di attivita' della cella: `resolved` vive solo dentro il ramo
|
|
320
|
+
// managed, e il payload del broker si costruisce FUORI. Portarlo fuori con
|
|
321
|
+
// una variabile dichiarata qui evita di usare, in un ramo, un nome
|
|
322
|
+
// dichiarato in un altro: un errore che in produzione fa fallire il lancio
|
|
323
|
+
// di ogni cella, non una sola asserzione.
|
|
324
|
+
let activityCanale = null;
|
|
294
325
|
if (engine.managed) {
|
|
295
326
|
// Generazione del lancio: identifica QUESTA partenza della cella. Uno
|
|
296
327
|
// stato di attivita' che ne porta un'altra appartiene a un lancio
|
|
@@ -301,6 +332,11 @@ function createBuiltinRuntime(ctx) {
|
|
|
301
332
|
...cfg, home, extraModels: extraModelsFrom(defs),
|
|
302
333
|
capabilityProfiles: defs.capabilityProfiles || null,
|
|
303
334
|
activityGeneration: generazione,
|
|
335
|
+
// La generazione NON si scrive qui: viaggia negli argv degli hook e nel
|
|
336
|
+
// payload del broker, e a scriverla su disco e' `cell-exec`, dentro la
|
|
337
|
+
// sessione che tmux ha appena creato — quindi solo per chi vince la
|
|
338
|
+
// new-session. Chi perde la corsa scriverebbe una generazione che non e'
|
|
339
|
+
// quella di nessun lancio vivo.
|
|
304
340
|
});
|
|
305
341
|
// Il materializzatore MCP per cella e' FAIL-CLOSED: se il file per cella
|
|
306
342
|
// non si scrive, la cella non parte — mai con la superficie intera dove
|
|
@@ -313,11 +349,10 @@ function createBuiltinRuntime(ctx) {
|
|
|
313
349
|
throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
|
|
314
350
|
}
|
|
315
351
|
launchEngine = resolved.engine;
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
if (resolved.activityDir) scriviGenerazione(resolved.activityDir, generazione);
|
|
352
|
+
// Qui si porta FUORI dal ramo il solo dato che serve al payload: dove e
|
|
353
|
+
// con quale generazione il supervisore dovra' PUBBLICARLA su disco e
|
|
354
|
+
// dichiarare l'uscita del client.
|
|
355
|
+
if (resolved.activityDir) activityCanale = { dir: resolved.activityDir, generation: generazione };
|
|
321
356
|
// A vl cell whose runtime cannot hold the per-cell prompt
|
|
322
357
|
// (VL_SYSTEM_APPEND_FILE, 0.3.1+) DEGRADES by declaring it — same
|
|
323
358
|
// shape as readinessDegraded: the cell starts without its own identity
|
|
@@ -392,6 +427,13 @@ function createBuiltinRuntime(ctx) {
|
|
|
392
427
|
...(typeof launchEngine.identityChannel === 'boolean'
|
|
393
428
|
? { identityChannel: launchEngine.identityChannel } : {}),
|
|
394
429
|
...(leaseInfo ? { lease: { cellId: cell.id, launchEpoch: leaseInfo.launchEpoch, stablePath: leaseInfo.stablePath } } : {}),
|
|
430
|
+
// Canale di attivita' verso il supervisore: il riavvio del client
|
|
431
|
+
// avviene DENTRO lo stesso lancio, quindi la generazione non cambia e
|
|
432
|
+
// l'unico modo di invalidare lo stato del client morto e' che sia
|
|
433
|
+
// cell-exec a dichiararlo. Presente solo dove gli hook sono davvero
|
|
434
|
+
// iniettati (`activityDir` lo dice): senza canale non c'e' niente da
|
|
435
|
+
// invalidare.
|
|
436
|
+
...(activityCanale ? { activity: activityCanale } : {}),
|
|
395
437
|
supervise: {
|
|
396
438
|
enabled: !launchEngine.shellOneShot,
|
|
397
439
|
initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
|
|
@@ -468,6 +510,26 @@ function createBuiltinRuntime(ctx) {
|
|
|
468
510
|
throw httpError(dup ? 409 : 500, why, null,
|
|
469
511
|
{ phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
|
|
470
512
|
}
|
|
513
|
+
// Il pane del SUPERVISORE si marca ORA, sul pane che esegue cell-exec: e'
|
|
514
|
+
// l'identita' stabile della cella viva. Il server cerca questo marcatore
|
|
515
|
+
// (`list-panes`) perche' `#{pane_dead}` di list-sessions riguarda il pane
|
|
516
|
+
// ATTIVO — con una seconda finestra viva selezionata direbbe «vivo» anche
|
|
517
|
+
// con il supervisore morto. L'id ce l'abbiamo gia' da `-P -F` (sotto), non
|
|
518
|
+
// serve una ricerca. Best effort come le opzioni che seguono: un tmux che
|
|
519
|
+
// non accetta l'opzione non deve impedire l'avvio — la cella resta senza
|
|
520
|
+
// marcatore, e per lei il server dira' «non verificato» invece di leggere
|
|
521
|
+
// uno stato di cui non sa identificare il supervisore.
|
|
522
|
+
const idPaneLancio = /^%[0-9]+$/.test((create.stdout.trim().split('\n')[0] || '').split('\t')[2] || '')
|
|
523
|
+
? (create.stdout.trim().split('\n')[0] || '').split('\t')[2] : '';
|
|
524
|
+
if (idPaneLancio) {
|
|
525
|
+
const marcato = await tmuxExec(tmuxBin,
|
|
526
|
+
['set-option', '-p', '-t', idPaneLancio, NC_SUPERVISOR_OPT, '1'],
|
|
527
|
+
{ env: minimalEnv(), timeoutMs: 2000 });
|
|
528
|
+
if (marcato.err) {
|
|
529
|
+
const log = typeof cfg.log === 'function' ? cfg.log : console.warn;
|
|
530
|
+
log(`fleet marcatore del pane supervisore non impostato per ${cell.id}; la cella resta non identificabile`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
471
533
|
// Best effort per la sola sessione appena creata: non tocchiamo mai
|
|
472
534
|
// ~/.tmux.conf, opzioni globali o sessioni preesistenti. Un tmux che non
|
|
473
535
|
// accetta una delle opzioni non deve impedire l'avvio della cella.
|
package/lib/proxy/panel-proxy.js
CHANGED
|
@@ -219,6 +219,20 @@ function respond(res, code, body) {
|
|
|
219
219
|
res.end(payload);
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
// Il frame NON deve finire carico-bianco su un pannello morto: il 502 qui è
|
|
223
|
+
// UNA PAGINA (l'iframe spara `load` anche sulle risposte d'errore), quindi la
|
|
224
|
+
// pagina dice sé stessa a chi la monta — un postMessage con la causa. Il
|
|
225
|
+
// contenuto è fisso e NON sensibile (un tipo, nessun dato), quindi il
|
|
226
|
+
// targetOrigin è '*': può nascere un padre di origine diversa (il frame viaggia
|
|
227
|
+
// sulla porta pannello separata proprio per isolare le origini). La fiducia la
|
|
228
|
+
// mette chi RICEVE: source del frame attivo + origine del frame + tipo atteso.
|
|
229
|
+
function respondPaginaNonRaggiungibile(res) {
|
|
230
|
+
res.writeHead(502, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
231
|
+
res.end('<!doctype html><html><body><p>pannello non raggiungibile</p><script>('
|
|
232
|
+
+ 'function(){try{window.parent.postMessage({type:"nc-panel-unreachable"},"*");}catch(_){}}'
|
|
233
|
+
+ ')()</script></body></html>');
|
|
234
|
+
}
|
|
235
|
+
|
|
222
236
|
function createPanelProxy({ resolveCellPanel, log = () => {}, requestImpl }) {
|
|
223
237
|
return async function panelProxy(req, res) {
|
|
224
238
|
const parsed = splitPanelPath(req.url);
|
|
@@ -248,13 +262,13 @@ function createPanelProxy({ resolveCellPanel, log = () => {}, requestImpl }) {
|
|
|
248
262
|
});
|
|
249
263
|
} catch (_) {
|
|
250
264
|
log({ event: 'panel-proxy', outcome: 'error', reason: 'request-failed', cell: parsed.cellId });
|
|
251
|
-
if (!res.headersSent)
|
|
265
|
+
if (!res.headersSent) respondPaginaNonRaggiungibile(res);
|
|
252
266
|
return;
|
|
253
267
|
}
|
|
254
268
|
upstream.setTimeout(PANEL_TIMEOUT_MS, () => upstream.destroy(new Error('panel timeout')));
|
|
255
269
|
upstream.on('error', () => {
|
|
256
270
|
log({ event: 'panel-proxy', outcome: 'error', reason: 'upstream-error', cell: parsed.cellId });
|
|
257
|
-
if (!res.headersSent)
|
|
271
|
+
if (!res.headersSent) respondPaginaNonRaggiungibile(res);
|
|
258
272
|
else res.destroy();
|
|
259
273
|
});
|
|
260
274
|
req.on('aborted', () => upstream.destroy());
|
package/lib/server.js
CHANGED
|
@@ -8,7 +8,8 @@ const express = require('express');
|
|
|
8
8
|
const { WebSocketServer } = require('ws');
|
|
9
9
|
const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
|
|
10
10
|
const { writeConfigAtomic } = require('./cli/init.js');
|
|
11
|
-
const { listSessions, attachedClients, setSessionVisibility } = require('./tmux/list.js');
|
|
11
|
+
const { listSessions, listPanes, attachedClients, setSessionVisibility } = require('./tmux/list.js');
|
|
12
|
+
const { statiSupervisore: statiSupervisoreDi, senzaSupervisore } = require('./tmux/supervisor-pane.js');
|
|
12
13
|
const { runAction, pasteToSession, submitToSession } = require('./tmux/actions.js');
|
|
13
14
|
const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
|
|
14
15
|
const { createPreviewSampler } = require('./tmux/preview.js');
|
|
@@ -20,7 +21,7 @@ const { requireToken, bearerFrom } = require('./auth/middleware.js');
|
|
|
20
21
|
const { filesRoutes } = require('./files/routes.js');
|
|
21
22
|
const { createOutboxWatcher } = require('./files/watcher.js');
|
|
22
23
|
const { leggiTelemetria } = require('./files/telemetry.js');
|
|
23
|
-
const { leggiAttivita } = require('./files/activity.js');
|
|
24
|
+
const { leggiAttivita, leggiGenerazione } = require('./files/activity.js');
|
|
24
25
|
const VERSION = require('../package.json').version;
|
|
25
26
|
const { transcribe } = require('./voice/transcribe.js');
|
|
26
27
|
const { selectProvider } = require('./fleet/provider.js');
|
|
@@ -976,6 +977,10 @@ function createServer(opts = {}) {
|
|
|
976
977
|
api.get('/sessions', async (_req, res) => {
|
|
977
978
|
try {
|
|
978
979
|
const sessions = await listSessions(cfg.tmuxBin);
|
|
980
|
+
// UNA chiamata per giro per sapere chi ha ancora il suo supervisore.
|
|
981
|
+
const statiSupervisore = sessions.length
|
|
982
|
+
? statiSupervisoreDi(await listPanes(cfg.tmuxBin))
|
|
983
|
+
: new Map();
|
|
979
984
|
const sum = watcher.getSummary();
|
|
980
985
|
const enriched = await Promise.all(sessions.map(async (s) => {
|
|
981
986
|
const sample = await previews.getState(s.name);
|
|
@@ -983,6 +988,7 @@ function createServer(opts = {}) {
|
|
|
983
988
|
// the capture fallback. This prevents a quoted "• Working (...)" line
|
|
984
989
|
// inside another agent's transcript from becoming a false positive.
|
|
985
990
|
const piTitle = /^(?:π|pi)(?:\s+-|$)/iu.test(s.paneTitle);
|
|
991
|
+
const statoDelSupervisore = statiSupervisore.get(s.name) || 'assente';
|
|
986
992
|
const working = s.working === true || (piTitle && sample?.working === true);
|
|
987
993
|
return {
|
|
988
994
|
...s,
|
|
@@ -999,7 +1005,28 @@ function createServer(opts = {}) {
|
|
|
999
1005
|
// nessun evento entro la finestra, oppure una cella che non ha questo
|
|
1000
1006
|
// canale (codex-vl, pi, shell). La UI non deve dedurre «ferma» da un
|
|
1001
1007
|
// silenzio: e' esattamente l'errore che il titolo del pane fa oggi.
|
|
1002
|
-
|
|
1008
|
+
//
|
|
1009
|
+
// E SI LEGGE SOLO SE IL SUPERVISORE DELLA CELLA ESISTE ANCORA. Con
|
|
1010
|
+
// `remain-on-exit` (runtime.js) la sessione sopravvive al suo
|
|
1011
|
+
// processo: un supervisore ucciso (SIGKILL, OOM) lascia la sessione
|
|
1012
|
+
// «attiva», e lo `Stop` con `exit:1` scritto prima resterebbe «ferma»
|
|
1013
|
+
// per sempre — mentre non c'e' nessun client a cui quello stato si
|
|
1014
|
+
// riferisca. La verita' e' il pane che esegue cell-exec, marcato al
|
|
1015
|
+
// lancio: `#{pane_dead}` della sessione riguarda il pane ATTIVO, e con
|
|
1016
|
+
// una seconda finestra viva selezionata direbbe «vivo» a supervisore
|
|
1017
|
+
// morto (misurato). La decisione completa sta in
|
|
1018
|
+
// `lib/tmux/supervisor-pane.js`: qui si passa lo stato del pane e — per
|
|
1019
|
+
// il solo caso «nessun marcatore» — il segno `exit:1` del file, che
|
|
1020
|
+
// distingue un lancio nuovo con marcatura fallita (non si legge: il
|
|
1021
|
+
// suo `Stop` non scadrebbe) da un lancio VECCHIO (si legge, e il
|
|
1022
|
+
// lettore fa scadere tutto a 5 minuti). E' quello che evita di
|
|
1023
|
+
// mostrare «non verificato» su ogni cella viva di ogni nodo appena
|
|
1024
|
+
// aggiornato.
|
|
1025
|
+
attivita: statoDelSupervisore === 'vivo'
|
|
1026
|
+
? leggiAttivita(cfg.filesRoot, s.name)
|
|
1027
|
+
: (senzaSupervisore(statoDelSupervisore, leggiGenerazione(path.join(cfg.filesRoot, s.name)).uscitaGarantita)
|
|
1028
|
+
? null
|
|
1029
|
+
: leggiAttivita(cfg.filesRoot, s.name)),
|
|
1003
1030
|
};
|
|
1004
1031
|
}));
|
|
1005
1032
|
res.json({ sessions: enriched });
|