@mmmbuto/nexuscrew 0.9.40 → 0.9.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ const net = require('node:net');
8
8
  const path = require('node:path');
9
9
  const { spawn } = require('node:child_process');
10
10
  const { MAX_PAYLOAD } = require('./launch-broker.js');
11
+ const { scriviStato, scriviGenerazione, AVVIO, USCITA } = require('../files/activity.js');
11
12
  const {
12
13
  validDaemonChallenge, validVerifyExpected, identityErrorCode,
13
14
  IDENTITY_FRAME_LIMIT, IDENTITY_TIMEOUT_MS,
@@ -71,17 +72,37 @@ function promptCharsOk(prompt) {
71
72
  return true;
72
73
  }
73
74
 
75
+ // Gate readiness MCP nel payload. I nomi attesi sono quelli del file
76
+ // cell-mcp materializzato (qualsiasi stringa safe <=64: la charset stretta
77
+ // vale solo sui valori che finiscono nell'opzione pane @nc_mcp_list).
78
+ function validMcpReadiness(value) {
79
+ if (value === undefined) return true;
80
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
81
+ if (Object.keys(value).some((key) => !['cwd', 'expectedServers', 'budgetMs', 'pollMs', 'cacheRoot'].includes(key))) return false;
82
+ return typeof value.cwd === 'string' && value.cwd.length > 0 && value.cwd.length <= 4096
83
+ && !/[\0\r\n]/.test(value.cwd)
84
+ && Array.isArray(value.expectedServers) && value.expectedServers.length > 0
85
+ && value.expectedServers.length <= 64
86
+ && value.expectedServers.every((n) => typeof n === 'string' && n.length >= 1
87
+ && n.length <= 64 && !/[\0\r\n]/.test(n))
88
+ && (value.budgetMs === undefined || validInteger(value.budgetMs, 0, 120000))
89
+ && (value.pollMs === undefined || validInteger(value.pollMs, 50, 5000))
90
+ && (value.cacheRoot === undefined || (typeof value.cacheRoot === 'string'
91
+ && value.cacheRoot.length > 0 && value.cacheRoot.length <= 4096 && !/[\0\r\n]/.test(value.cacheRoot)));
92
+ }
93
+
74
94
  function validRestartPrompt(value) {
75
95
  if (value === undefined) return true;
76
96
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
77
- if (Object.keys(value).some((key) => !['tmuxBin', 'tmuxSession', 'prompt', 'readyMs', 'client', 'readyWaitMs'].includes(key))) return false;
97
+ if (Object.keys(value).some((key) => !['tmuxBin', 'tmuxSession', 'prompt', 'readyMs', 'client', 'readyWaitMs', 'mcpReadiness'].includes(key))) return false;
78
98
  return typeof value.tmuxBin === 'string' && value.tmuxBin.length > 0 && value.tmuxBin.length <= 4096
79
99
  && !/[\0\r\n]/.test(value.tmuxBin)
80
100
  && typeof value.tmuxSession === 'string' && /^[\w.@%:+-]{1,128}$/.test(value.tmuxSession)
81
101
  && promptCharsOk(value.prompt)
82
102
  && (value.readyMs === undefined || validInteger(value.readyMs, 0, 30000))
83
103
  && (value.client === undefined || value.client === '' || value.client === 'kimi' || value.client === 'claude')
84
- && (value.readyWaitMs === undefined || validInteger(value.readyWaitMs, 0, 120000));
104
+ && (value.readyWaitMs === undefined || validInteger(value.readyWaitMs, 0, 120000))
105
+ && (value.mcpReadiness === undefined || validMcpReadiness(value.mcpReadiness));
85
106
  }
86
107
 
87
108
  function validIdentity(value) {
@@ -109,9 +130,24 @@ function validLease(value) {
109
130
  && typeof value.stablePath === 'string' && value.stablePath.length > 0 && value.stablePath.length <= 4096;
110
131
  }
111
132
 
133
+ // Canale di attivita' della cella: dove il supervisore deve dichiarare l'uscita
134
+ // del client, e con quale generazione. Presente SOLO quando il launcher ha
135
+ // davvero iniettato gli hook (managed.js pubblica la generazione prima di
136
+ // costruire gli argv): senza canale non c'e' niente da invalidare, e un payload
137
+ // senza il campo conserva il comportamento di prima.
138
+ function validActivity(value) {
139
+ if (value === undefined) return true;
140
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
141
+ if (Object.keys(value).some((key) => !['dir', 'generation'].includes(key))) return false;
142
+ return typeof value.dir === 'string' && value.dir.length > 0 && value.dir.length <= 4096
143
+ && !/[\0\r\n]/.test(value.dir)
144
+ && typeof value.generation === 'string' && value.generation.length > 0 && value.generation.length <= 128
145
+ && !/[\0\r\n]/.test(value.generation);
146
+ }
147
+
112
148
  function validPayload(payload) {
113
149
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
114
- if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity', 'identityChannel'].includes(key))) return false;
150
+ if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity', 'identityChannel', 'activity'].includes(key))) return false;
115
151
  if (typeof payload.command !== 'string' || !payload.command || !Array.isArray(payload.args)) return false;
116
152
  if (!payload.env || typeof payload.env !== 'object' || Array.isArray(payload.env)) return false;
117
153
  // Il launcher decide se il canale identita' esiste (managed.js) e lo dichiara
@@ -124,6 +160,7 @@ function validPayload(payload) {
124
160
  && validSupervise(payload.supervise)
125
161
  && validRestartPrompt(payload.restartPrompt)
126
162
  && validLease(payload.lease)
163
+ && validActivity(payload.activity)
127
164
  && validIdentity(payload.identity);
128
165
  }
129
166
 
@@ -428,10 +465,36 @@ function waitChild(child) {
428
465
  // post-paste incerto (delivery-unknown / staged-not-submitted) — il main loop
429
466
  // NON auto-restarta in quel caso (leftover bytes potenzialmente residui nel PTY del
430
467
  // pane riusato; fermo bounded + restart operatore).
468
+ // Gate readiness MCP per le claude.* (config.mcpReadiness dal payload).
469
+ // La deadline e' COMPOSTA sullo stesso orologio del launch (notBeforeMs =
470
+ // avvio della GENERAZIONE corrente, mai handshake di una generazione
471
+ // precedente; budgetMs dal tetto approvato) e la cancellazione ferma il polling
472
+ // prima del respawn (isCancelled valutato ad ogni poll dalla deliver).
473
+ function buildMcpWait(config, seams) {
474
+ const mr = config && config.mcpReadiness;
475
+ if (!mr || !Array.isArray(mr.expectedServers) || mr.expectedServers.length === 0) return null;
476
+ const now = seams.nowImpl || Date.now;
477
+ const startedAt = now();
478
+ const budget = Math.max(0, Number(mr.budgetMs) || 20000);
479
+ const waitImpl = seams.waitMcpReadiness || require('./mcp-cache-readiness.js').waitMcpReadiness;
480
+ return async ({ isCancelled }) => waitImpl({
481
+ params: { cacheRoot: mr.cacheRoot, cwd: mr.cwd, expectedServers: mr.expectedServers, notBeforeMs: startedAt },
482
+ deadlineMs: startedAt + budget,
483
+ pollMs: mr.pollMs || 500,
484
+ sleepImpl: seams.sleepImpl,
485
+ nowImpl: seams.nowImpl,
486
+ isCancelled,
487
+ });
488
+ }
489
+
490
+ const MCP_DELIVERY_STATES = ['ready', 'degraded'];
491
+ const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
492
+
431
493
  function startGenerationPrompt(config, generation, childState, seams = {}) {
432
494
  if (!config) return null;
433
495
  const classified = config.client === 'kimi' || config.client === 'claude';
434
496
  if (!classified && generation === 0) return null; // legacy: gen0 resta al runtime
497
+ const mcpWait = buildMcpWait(config, seams);
435
498
  const setTimer = seams.setTimeout || setTimeout;
436
499
  const clearTimer = seams.clearTimeout || clearTimeout;
437
500
  const runTmux = seams.tmuxExec || ((bin, args, opts = {}) => new Promise((resolve) => {
@@ -444,6 +507,19 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
444
507
  try { await runTmux(config.tmuxBin, ['set-option', '-p', '-t', paneTarget, '@nc_delivery', value], {}); }
445
508
  catch (_) { /* best-effort: il report timeout di up() resta onesto */ }
446
509
  };
510
+ // Elenco bounded dei server non pronti su @nc_mcp_list (solo nomi
511
+ // validati e cap 16 per classe; muore col pane come @nc_delivery).
512
+ const markMcpList = async (mcp) => {
513
+ if (!classified || !mcp) return;
514
+ const valida = (arr) => (Array.isArray(arr) ? arr : [])
515
+ .filter((n) => typeof n === 'string' && MCP_NAME_RE.test(n)).slice(0, 16).sort();
516
+ const f = valida(mcp.failed);
517
+ const p = valida(mcp.pending);
518
+ if (!f.length && !p.length) return;
519
+ const value = `${f.length ? `failed=${f.join(',')}` : ''}${f.length && p.length ? '|' : ''}${p.length ? `pending=${p.join(',')}` : ''}`;
520
+ try { await runTmux(config.tmuxBin, ['set-option', '-p', '-t', paneTarget, '@nc_mcp_list', value], {}); }
521
+ catch (_) { /* best-effort come @nc_delivery */ }
522
+ };
447
523
  let timer = null; let cancelled = false;
448
524
  let settledDone = false; let settleResolve = null;
449
525
  const settled = new Promise((resolve) => { settleResolve = resolve; });
@@ -470,6 +546,7 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
470
546
  paneTarget: process.env.TMUX_PANE || undefined,
471
547
  readyWaitMs: config.readyWaitMs,
472
548
  isCancelled,
549
+ ...(mcpWait ? { mcpWait } : {}),
473
550
  });
474
551
  const state = result && typeof result.state === 'string' ? result.state : '';
475
552
  const uncertain = state === 'delivery-unknown' || state === 'staged-not-submitted';
@@ -480,7 +557,10 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
480
557
  }
481
558
  if (state) {
482
559
  const kind = state === 'skipped-not-ready' && result.notReady ? `:${result.notReady}` : '';
483
- await markDelivery(`${state}${kind}`);
560
+ const mcpState = result && result.mcp && MCP_DELIVERY_STATES.includes(result.mcp.state)
561
+ ? `:mcp${result.mcp.state}` : '';
562
+ await markDelivery(`${state}${kind}${mcpState}`);
563
+ if (mcpState) await markMcpList(result.mcp);
484
564
  }
485
565
  // L'esito post-paste incerto va conservato ANCHE se il child
486
566
  // era vivo al ritorno di deliver: i byte possono restare nel PTY del
@@ -527,6 +607,20 @@ async function main(argv = process.argv.slice(2), seams = {}) {
527
607
  const sleep = seams.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
528
608
  const proc = seams.process || process;
529
609
  const writeError = seams.writeError || seams.stderrWrite || ((message) => process.stderr.write(message));
610
+ const activity = payload.activity || null;
611
+ // Uscita del client: il supervisore DICHIARA che il processo che pubblicava lo
612
+ // stato non c'e' piu'. Serve perche' il riavvio avviene DENTRO lo stesso
613
+ // lancio (loop sotto): `activity.gen` non cambia, quindi la generazione non
614
+ // puo' accorgersi che il client precedente e' morto. Senza questa riga uno
615
+ // `Stop` dell'ultimo turno resterebbe «ferma» per tutto il backoff — e per
616
+ // sempre, ora che «ferma» non scade — mentre il client e' morto e il prossimo
617
+ // non e' pronto. Il lettore mappa l'evento a null: «non verificato».
618
+ const dichiaraUscita = () => {
619
+ if (!activity) return;
620
+ if (scriviStato(activity.dir, { evento: USCITA, generazione: activity.generation, ora: now() }) !== true) {
621
+ writeError('nexuscrew cell: uscita del client non registrata nello stato di attivita\n');
622
+ }
623
+ };
530
624
  // Il canale identita' nasce SOLO se il launcher lo ha deciso: la stessa
531
625
  // risoluzione che decide CODEX_APP_SERVER_IDENTITY_REQUIRED in managed.js
532
626
  // viaggia nel payload come `identityChannel`. Derivarlo qui (aggiungere
@@ -616,6 +710,55 @@ async function main(argv = process.argv.slice(2), seams = {}) {
616
710
  for (const [signal, handler] of handlers) proc.off?.(signal, handler);
617
711
  };
618
712
 
713
+ // LA GENERAZIONE SI SCRIVE QUI, E SOLO QUI.
714
+ //
715
+ // Questo processo gira DENTRO la sessione tmux appena creata: chi perde la
716
+ // corsa sulla new-session non arriva mai a un cell-exec e quindi non scrive
717
+ // niente. Scriverla prima (nella risoluzione) significava scriverla anche per
718
+ // il perdente: sul disco restava la generazione di chi non aveva lanciato
719
+ // nulla, mentre gli hook del vincitore portavano la loro — e ogni suo evento
720
+ // veniva scartato al lettore, cioe' «non verificato» su una cella che
721
+ // lavorava.
722
+ //
723
+ // Prima del primo spawn, non dentro il ciclo: la generazione identifica il
724
+ // LANCIO, e i riavvii interni del client (il ciclo sotto) restano lo stesso
725
+ // lancio. Il client parte DOPO questa scrittura: mai un hook che scrive una
726
+ // generazione che non e' ancora su disco.
727
+ if (activity) {
728
+ // (1) INVALIDA lo stato lasciato dal client precedente, PRIMA di pubblicare
729
+ // la generazione. Un evento di lancio porta la generazione nuova e il
730
+ // lettore lo mappa a null: senza, un «ferma» dell'ultimo turno del client
731
+ // precedente sopravviverebbe al lancio — e con «ferma» che non scade
732
+ // resterebbe vero finche' un hook non lo rinnova, cioe' per sempre su una
733
+ // cella appena partita. Scrivo prima della generazione di proposito:
734
+ // l'invalidazione non deve dipendere dalla pubblicazione riuscita.
735
+ if (scriviStato(activity.dir, {
736
+ evento: AVVIO, generazione: activity.generation, ora: now(),
737
+ }) !== true) {
738
+ // LIMITE DICHIARATO (accettato come tale, non un percorso normale): se la
739
+ // directory e' diventata non scrivibile fra due lanci, QUI non si scrive
740
+ // niente e lo stato che la UI mostra puo' essere QUELLO DEL LANCIO
741
+ // PRECEDENTE — un «ferma» dell'ultimo turno di un client che non c'e'
742
+ // piu'. Il lettore non puo' accorgersene: la generazione su disco e'
743
+ // ancora la vecchia, e gli eventi nuovi non arrivano finche' un hook non
744
+ // scrive. E' un guasto del filesystem durante la vita della cella (la
745
+ // directory e' stata resa non scrivibile), non un esito che il codice
746
+ // possa riparare da qui: si dichiara nel log, con la conseguenza.
747
+ writeError("nexuscrew cell: stato di attivita' precedente non invalidato (directory non scrivibile); "
748
+ + "lo stato mostrato puo' essere quello del lancio precedente\n");
749
+ }
750
+ // (2) PUBBLICA la generazione, e con essa il segno che questo supervisore
751
+ // garantisce l'evento di uscita: e' quel segno a permettere la non scadenza
752
+ // di «ferma»/«interrotta» nel lettore.
753
+ if (scriviGenerazione(activity.dir, activity.generation, { uscitaGarantita: true }) !== true) {
754
+ // Scrittura fallita: si avvia lo stesso, e lo si dichiara. Il client
755
+ // scrivera' eventi con una generazione che non e' quella su disco, quindi
756
+ // verranno scartati e la cella risultera' «non verificato» — l'esito
757
+ // sicuro, mai «ferma» senza fondamento.
758
+ writeError("nexuscrew cell: generazione di attivita' non pubblicata, stato non verificabile\n");
759
+ }
760
+ }
761
+
619
762
  let delayMs = supervise.restartDelayMs;
620
763
  let rapid = [];
621
764
  try {
@@ -719,6 +862,11 @@ async function main(argv = process.argv.slice(2), seams = {}) {
719
862
  childState.exited = true;
720
863
  closeIdentityChannel();
721
864
  current = null;
865
+ // Il client non c'e' piu': QUALUNQUE sia la causa (uscita normale, segnale,
866
+ // errore di spawn, stop dell'operatore) lo stato pubblicato non e' piu'
867
+ // verificabile. Prima di ogni return del supervisore, cosi' nessuna delle
868
+ // uscite resta con lo stato del client morto.
869
+ dichiaraUscita();
722
870
  // : la generazione e' finita. Cancella la delivery in volo e ATTESA
723
871
  // Del suo termine PRIMA di qualunque nuovo spawn.: se l'esito e' un
724
872
  // post-paste incerto (delivery-unknown / staged-not-submitted) i byte del
@@ -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
+ };