@mmmbuto/nexuscrew 0.9.16 → 0.9.17

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +132 -3
  2. package/docs/ALIBABA_TOKEN_PLAN.md +70 -0
  3. package/docs/AUDIO_SHARE.md +119 -0
  4. package/docs/CELL_PANEL.md +156 -0
  5. package/docs/CONFIGURATION.md +122 -0
  6. package/docs/FLEET.md +222 -0
  7. package/docs/INSTALLATION.md +128 -0
  8. package/docs/LIVE_PROMPT.md +110 -0
  9. package/docs/MCP.md +151 -0
  10. package/docs/NODES.md +214 -0
  11. package/docs/NOTIFICATIONS.md +101 -0
  12. package/docs/OPERATIONS.md +114 -0
  13. package/docs/README.md +55 -0
  14. package/docs/SECURITY.md +185 -0
  15. package/docs/VL_MICRO_NODES.md +107 -0
  16. package/docs/img/fleet-deck-desktop.png +0 -0
  17. package/docs/img/session-mobile.png +0 -0
  18. package/docs/live-prompt-templates/LIVE_PROMPT.en.md +67 -0
  19. package/docs/live-prompt-templates/LIVE_PROMPT.es.md +70 -0
  20. package/docs/live-prompt-templates/LIVE_PROMPT.it.md +67 -0
  21. package/frontend/dist/assets/{index-CwsGpQwK.js → index-jBYBPXhB.js} +1 -1
  22. package/frontend/dist/index.html +1 -1
  23. package/frontend/dist/version.json +1 -1
  24. package/lib/auth/middleware.js +1 -1
  25. package/lib/cells/scope-guard.js +2 -2
  26. package/lib/cli/commands.js +7 -7
  27. package/lib/cli/fleet-service.js +2 -2
  28. package/lib/cli/pidfile.js +15 -15
  29. package/lib/cli/service.js +2 -2
  30. package/lib/cli/stable-alias.js +1 -1
  31. package/lib/files/routes.js +2 -2
  32. package/lib/fleet/builtin.js +6 -6
  33. package/lib/fleet/cell-exec.js +4 -4
  34. package/lib/fleet/cell-lease-server.js +17 -17
  35. package/lib/fleet/definitions.js +5 -5
  36. package/lib/fleet/launch-broker.js +3 -3
  37. package/lib/fleet/lease-client.js +1 -1
  38. package/lib/fleet/managed.js +11 -11
  39. package/lib/fleet/prompt-delivery.js +1 -1
  40. package/lib/fleet/runtime.js +3 -3
  41. package/lib/live-host/bridge.js +3 -3
  42. package/lib/live-host/routes.js +1 -1
  43. package/lib/mcp/server.js +7 -8
  44. package/lib/mcp/tools.js +1 -1
  45. package/lib/nodes/commands.js +2 -2
  46. package/lib/nodes/health.js +1 -1
  47. package/lib/nodes/identity.js +10 -13
  48. package/lib/nodes/store.js +2 -2
  49. package/lib/nodes/tunnel-supervisor.js +7 -7
  50. package/lib/nodes/tunnel.js +10 -11
  51. package/lib/notify/asks.js +4 -4
  52. package/lib/notify/persist.js +1 -1
  53. package/lib/notify/push.js +4 -4
  54. package/lib/notify/routes.js +12 -12
  55. package/lib/proxy/node-proxy.js +4 -4
  56. package/lib/proxy/panel-proxy.js +2 -2
  57. package/lib/pty/attach.js +1 -1
  58. package/lib/server.js +3 -3
  59. package/lib/settings/pairing-coordinator.js +1 -1
  60. package/lib/settings/routes.js +4 -4
  61. package/lib/tmux/lifecycle.js +4 -4
  62. package/lib/tmux/preview.js +1 -1
  63. package/lib/update/runner.js +9 -9
  64. package/lib/vl-nodes/broker.js +1 -1
  65. package/lib/ws/bridge.js +1 -1
  66. package/package.json +20 -2
  67. package/frontend/index.html +0 -20
@@ -103,7 +103,7 @@ function declaredFor(extraModels, profileId, model) {
103
103
  // uno la dimentichi perche' un modello dichiarato smetta di essere valido
104
104
  // proprio nel punto che conta — l'avvio.
105
105
  //
106
- // D2: Map<engine, Map<id, model>> — porta il descrittore intero, non solo
106
+ // da revisione: Map<engine, Map<id, model>> — porta il descrittore intero, non solo
107
107
  // l'id (stesso motivo del commento gemello in definitions.js: extraModels e'
108
108
  // costruita due volte, una dentro parseDefinitions per il parsing, una qui per
109
109
  // il runtime che rilegge le definizioni gia' salvate; le due DEVONO restare
@@ -767,7 +767,7 @@ function binaryCandidates(client, home) {
767
767
  // (non possiamo dichiararlo "trovato"); ma chi costruisce il messaggio
768
768
  // (describeManaged) puo' ora distinguere "client non trovato" da "non ho potuto
769
769
  // verificare un candidato" — il discriminante e' CHI ha fallito, non che ci sia
770
- // stata un'eccezione. Stesso principio gia' applicato in checkTermuxExec (3134d2f).
770
+ // stata un'eccezione. Stesso principio gia' applicato in checkTermuxExec.
771
771
  function findBinary(client, home, out) {
772
772
  const blocked = Array.isArray(out && out.blocked) ? out.blocked : null;
773
773
  for (const candidate of binaryCandidates(client, home)) {
@@ -1061,7 +1061,7 @@ function describeManaged(spec, cfg = {}) {
1061
1061
  const extraModels = cfg.extraModels || null;
1062
1062
  const normalized = normalizeManagedSpec(spec, { extraModels });
1063
1063
  if (!normalized) {
1064
- // D2: il rifiuto resta (i descrittori NON appartengono al profilo di una
1064
+ // da revisione: il rifiuto resta (i descrittori NON appartengono al profilo di una
1065
1065
  // cella: vengono dalla definizione dell'ENGINE, in `d.models` — due
1066
1066
  // soggetti diversi, mescolarli renderebbe ambiguo chi dichiara cosa). Ma
1067
1067
  // se la causa e' proprio questa, il messaggio generico "invalid managed
@@ -1238,7 +1238,7 @@ function sharedMcpArgs(home) {
1238
1238
  // indicizzato sul percorso assoluto — ed e' reale: su questa installazione nove
1239
1239
  // progetti ne hanno, fra cui la directory di lavoro delle celle. Dimenticarlo
1240
1240
  // significava che un elenco parziale non negava server che la sessione carica
1241
- // davvero, e l'operatore avrebbe creduto di averli esclusi. Trovato dall'audit.
1241
+ // davvero, e l'operatore avrebbe creduto di averli esclusi. Trovato da una revisione.
1242
1242
  //
1243
1243
  // RESTANO FUORI, e non sono enumerabili da qui: plugin, connettori claude.ai e
1244
1244
  // la configurazione gestita di sistema. Per quelli l'unica esclusione certa e'
@@ -1356,7 +1356,7 @@ function ensurePrivateClaudeConfig(home, profileId, label, penguinMode) {
1356
1356
  return configDir;
1357
1357
  }
1358
1358
 
1359
- // D2 audit: il file .ts generato NON e' il consumatore — Pi lo E'. Un test che
1359
+ // da revisione: il file .ts generato NON e' il consumatore — Pi lo E'. Un test che
1360
1360
  // legge solo il file resta verde su un'estensione che Pi rifiuta a runtime.
1361
1361
  // CONTRATTO REALE che Pi (@earendil-works/pi-coding-agent 0.80.10) impone a
1362
1362
  // ogni modello di `pi.registerProvider(id, {models: [...]})` — fonte:
@@ -1382,13 +1382,13 @@ function toPiModelConfig(m) {
1382
1382
  };
1383
1383
  }
1384
1384
 
1385
- // D2: `declaredModels` (opzionale) sono i descrittori dichiarati per l'ENGINE
1385
+ // da revisione: `declaredModels` (opzionale) sono i descrittori dichiarati per l'ENGINE
1386
1386
  // (via declaredModelsFor), per il ramo Pi CUSTOM — stesso motivo/stessa fonte
1387
1387
  // di customCatalogFor, mai letti da `spec.models`. Il ramo Pi NON-custom
1388
1388
  // (profile.piExtension) continua a passare `models` dentro l'oggetto spec-like
1389
1389
  // come sempre: e' un catalogo STATICO cablato nel codice (es. alibaba-token-
1390
1390
  // plan), GIA' nella forma completa che Pi si aspetta — un caso diverso, non
1391
- // toccato dal difetto D2. declaredModels, quando presente, ha priorita', ed e'
1391
+ // toccato dal difetto da revisione. declaredModels, quando presente, ha priorita', ed e'
1392
1392
  // sempre passato per toPiModelConfig (mai i descrittori grezzi cosi' come sono).
1393
1393
  function writePiProviderExtension(spec, home, declaredModels) {
1394
1394
  const dir = path.join(home, '.nexuscrew', 'pi-providers');
@@ -1524,7 +1524,7 @@ function vlSystemAppendGate(binary, cfg) {
1524
1524
  return { ok: true };
1525
1525
  }
1526
1526
 
1527
- // D2 (fix definitivo — l'audit del pacchetto aveva bocciato la prima versione:
1527
+ // (fix definitivo — una revisione del pacchetto aveva bocciato la prima versione:
1528
1528
  // il test costruiva `spec.models` a mano, e in produzione quel campo non
1529
1529
  // esiste mai). `declaredModels` NON viene da `spec`: i descrittori sono
1530
1530
  // proprieta' della definizione dell'ENGINE (`d.models` del documento), non del
@@ -1614,14 +1614,14 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1614
1614
  // non basta: la normalizzazione che decide se la cella PARTE e' questa, e
1615
1615
  // senza l'elenco dichiarato un modello legittimo diventa «invalid managed
1616
1616
  // profile» — cioe' una cella che non si avvia per una ragione invisibile.
1617
- // E' il difetto che l'audit ha ripreso due volte: la vista diceva di si',
1617
+ // E' il difetto che la revisione ha ripreso due volte: la vista diceva di si',
1618
1618
  // il boot diceva di no, e nessun test guardava il boot.
1619
1619
  const spec = normalizeManagedSpec(engine.managed, { extraModels: cfg.extraModels || null });
1620
1620
  const info = describeManaged(spec, cfg);
1621
1621
  if (!spec || !info.configured) return { ok: false, reason: info.reason, info };
1622
1622
  const home = cfg.home || require('node:os').homedir();
1623
1623
  const profile = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
1624
- // D2: i descrittori dichiarati per QUESTO profilo (client.provider — la
1624
+ // da revisione: i descrittori dichiarati per QUESTO profilo (client.provider — la
1625
1625
  // stessa chiave che declaredFor usa dentro normalizeManagedSpec per
1626
1626
  // validare gli id). Vengono dalla definizione dell'ENGINE (extraModels),
1627
1627
  // MAI da spec: e' il ponte che customCatalogFor/writePiProviderExtension
@@ -1845,7 +1845,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1845
1845
  } else if (spec.provider === 'custom') {
1846
1846
  env[spec.envKey] = cred.value;
1847
1847
  args.push(...codexProviderArgs(spec.providerId, spec.displayName, spec.baseUrl, spec.envKey));
1848
- // D2: onora `models` (engine definition, già validato) come gli altri
1848
+ // da revisione: onora `models` (engine definition, già validato) come gli altri
1849
1849
  // provider — deriva model_catalog_json e model_context_window, così
1850
1850
  // Codex-VL non ricade sul fallback 272K. Senza `models` -> null (no regressione).
1851
1851
  const customMeta = customCatalogFor(spec, model, declaredModels, home);
@@ -4,7 +4,7 @@
4
4
  // managed conservano il prompt su argv (finding separato) e i custom send-keys
5
5
  // conservano injectPrompt legacy senza Enter.
6
6
  //
7
- // Contratto (design audit 0.8.47):
7
+ // Contratto (design della revisione 0.8.47):
8
8
  // - Readiness REALE prima della consegna: il pane viene classificato sul SOLO
9
9
  // viewport visibile (capture-pane -p, mai scrollback -S: su generation>0 lo
10
10
  // scrollback puo' contenere vecchi prompt e marker stale). Auth, consenso
@@ -80,7 +80,7 @@ function createBuiltinRuntime(ctx) {
80
80
  // solo — il ponte Live, che la chiede esplicitamente e in-processo — quindi
81
81
  // e' lui a doverla chiedere, non tutti gli altri a doversene ricordare.
82
82
  // Il backup vieta gia' le cwd assolute per la stessa ragione: sono specifiche
83
- // del dispositivo. Rilievo di un audit indipendente.
83
+ // del dispositivo. Rilievo di una revisione indipendente.
84
84
  async function cellStatus({ includeCwd = false } = {}) {
85
85
  if (Date.now() - cache.at > STATUS_TTL_MS) {
86
86
  reloadDefs(); // pick-up di edit esterne/file
@@ -100,7 +100,7 @@ function createBuiltinRuntime(ctx) {
100
100
  const effectivePolicy = ['pi', 'shell'].includes(engineDef?.managed?.client)
101
101
  ? 'standard'
102
102
  : (remembered || engineDefault || '');
103
- // D8: panelUrl per-cella vince su quello precompilato dall'engine (es.
103
+ // da revisione: panelUrl per-cella vince su quello precompilato dall'engine (es.
104
104
  // desktop.local), che a sua volta e' il default. Stessa forma di
105
105
  // engineDef gia' usata sopra per la permission policy: un valore
106
106
  // presente qui e' gia' passato da validPanelUrl a monte (parseEngine/
@@ -222,7 +222,7 @@ function createBuiltinRuntime(ctx) {
222
222
  // broker-backed supervisor: credentials never enter tmux state/argv and a
223
223
  // client that exits after readiness is restarted with bounded backoff.
224
224
  // '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
225
- // cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
225
+ // cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (da revisione impl
226
226
  // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
227
227
  const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
228
228
  const child = composeClientInvocation(launchEngine, cell);
@@ -22,7 +22,7 @@
22
22
  // emendata da rev5 MC2). La riga che decide è in codex-rs
23
23
  // core/src/config/mod.rs: `developer_instructions.or(cfg.developer_
24
24
  // instructions)` — l'override Some scarta il valore di config. R2
25
- // (2026-08-16, verso corretto dopo audit pre-release): l'identità della
25
+ // (verso corretto dopo revisione pre-release): l'identità della
26
26
  // cella designata viaggia SEMPRE come intestazione anteposta al campo,
27
27
  // anche senza prompt. Il campo NON è additivo: una cella senza
28
28
  // LIVE_PROMPT.md, che prima non passava nulla e riceveva le developer
@@ -227,7 +227,7 @@ function readCellPrompt(filesRoot, tmuxSession) {
227
227
  // il ponte: il nome esatto della sessione, che il server MCP accetta da
228
228
  // NEXUSCREW_MCP_SESSION via stdio. Il valore NON si mette in systemd
229
229
  // Environment= (una identità statica condivisa = impersonare una cella
230
- // fissa, demolito in audit v1): lo dice l'intestazione, per-cella.
230
+ // fissa, demolito in revisione v1): lo dice l'intestazione, per-cella.
231
231
  //
232
232
  // Il fatto, niente di più: quale cella (id Fleet) e, se il roster la dichiara,
233
233
  // la sessione tmux esatta — quella con cui la voce raggiunge i canonici della
@@ -497,7 +497,7 @@ function createLiveBridge({
497
497
  // Tre condizioni diverse, tre nomi: la designazione dichiarata non
498
498
  // eleggibile dall'hub, la cella che non esiste piu' nel roster, e la cella
499
499
  // che c'e' ma e' spenta. Un nome solo mandava a guardare l'hub anche quando
500
- // il problema era una sessione chiusa. Rilievo di un audit indipendente.
500
+ // il problema era una sessione chiusa. Rilievo di una revisione indipendente.
501
501
  if (snap.eligible !== true) return none('host-ineligible');
502
502
 
503
503
  let cell;
@@ -39,7 +39,7 @@ function isActive(cell) {
39
39
  return !!(cell && cell.active === true && cell.tmux !== false);
40
40
  }
41
41
 
42
- // --- Seam lease↔designazione (2026-08-15, decisione presa in revisione: grace = false) -----
42
+ // --- Seam lease↔designazione (decisione presa in revisione: grace = false) -----
43
43
  //
44
44
  // L'idoneita' dell'host designato non e' piu' solo «sessione tmux viva»: con
45
45
  // remain-on-exit la sessione sopravvive alla morte del supervisore, e la
package/lib/mcp/server.js CHANGED
@@ -162,7 +162,7 @@ function resolveIdentity({ env, tmuxBin, execFileImpl }) {
162
162
  return settle(missing);
163
163
  }
164
164
  const name = String(stdout || '').trim();
165
- // tmux 3.4, misura dell'audit (probe A1/A2): pane morto con -t ->
165
+ // tmux 3.4, misura della revisione (probe doppia): pane morto con -t ->
166
166
  // rc=0 e stdout VUOTO. Il vuoto è il segnale dello stantio.
167
167
  if (!name) return settle(stalePane);
168
168
  if (isValidSession(name)) {
@@ -288,12 +288,11 @@ function createMcpServer(opts = {}) {
288
288
  return j;
289
289
  }
290
290
 
291
- // NC-R. Aggiornare NexusCrew NON aggiorna il bridge MCP delle celle gia' in
292
- // piedi: quel processo e' partito col codice di prima e ci resta fino al
293
- // riavvio della cella. Il sintomo e' crudele — si installa una correzione, si
294
- // riprova, e si riceve l'errore VECCHIO e chi lo subisce conclude che la
295
- // correzione non funziona. E' successo il 2026-08-07 su rc.26, a me, e ci ho
296
- // messo un giro intero a capirlo.
291
+ // NC-R. Aggiornare NexusCrew NON cambia il codice gia' caricato dai bridge
292
+ // MCP delle celle attive: ogni processo resta sulla versione con cui e' stato
293
+ // avviato fino al riavvio della cella. Dopo una correzione installata, un
294
+ // bridge ancora attivo puo' quindi restituire l'errore precedente; prima di
295
+ // valutare la correzione vanno confrontate le versioni di hub e bridge.
297
296
  //
298
297
  // Il momento in cui serve saperlo e' esattamente quello in cui qualcosa
299
298
  // fallisce, quindi la verifica sta SOLO sul ramo d'errore: a regime non costa
@@ -367,7 +366,7 @@ function createMcpServer(opts = {}) {
367
366
  return replyError(null, INVALID_REQUEST, 'richiesta non valida (atteso oggetto JSON-RPC)');
368
367
  }
369
368
  const id = (typeof msg.id === 'string' || typeof msg.id === 'number') ? msg.id : undefined;
370
- // F6 (audit): SOLO JSON-RPC 2.0 — versione assente/errata -> -32600 anche
369
+ // da revisione: SOLO JSON-RPC 2.0 — versione assente/errata -> -32600 anche
371
370
  // per i messaggi senza id (il no-op vale solo per notification 2.0 valide).
372
371
  if (msg.jsonrpc !== '2.0') {
373
372
  return replyError(id !== undefined ? id : null, INVALID_REQUEST, 'jsonrpc "2.0" richiesto');
package/lib/mcp/tools.js CHANGED
@@ -345,7 +345,7 @@ const TOOLS = [
345
345
  // Restituirlo cosi' evita di far leggere un successo dove c'e' un rifiuto.
346
346
  // Nessun conteggio: il dispatcher non lo propaga, e un ramo che non puo'
347
347
  // mai essere vero e' codice morto travestito da informazione
348
- // (rilievo R1 di un audit indipendente su rc.14). Se un giorno servira' il dettaglio,
348
+ // (rilievo di una revisione indipendente). Se un giorno servira' il dettaglio,
349
349
  // va fatto propagare da forward(), non dedotto qui.
350
350
  if (target) return { status: j.status, ...(j.reason ? { reason: j.reason } : {}) };
351
351
  return { delivered: j.delivered };
@@ -204,7 +204,7 @@ function nodesAdd(opts) {
204
204
 
205
205
  log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh}${sshPort ? `:${sshPort}` : ''}, nexus remoto ${remotePort} -> locale ${localPort})`);
206
206
  log('Incolla nel ~/.ssh/authorized_keys del NODO (lato forward, chiave dedicata):');
207
- // R19: permitopen con le destinazioni EXPLICITE dei -L. Qui si conosce solo
207
+ // da revisione: permitopen con le destinazioni EXPLICITE dei -L. Qui si conosce solo
208
208
  // la porta nexus: la porta pannello del peer la annuncia il JOIN, non
209
209
  // esiste ancora — quando apparirà, la riga completa (due destinazioni) la
210
210
  // emette il pairing. Mai un permesso generico: è ciò che impedisce a questa
@@ -392,7 +392,7 @@ function nodesRemove(opts) {
392
392
  let next;
393
393
  try { next = store.removeNode(st, name); }
394
394
  catch (e) { log(`nodes remove: ${e.message}`); return { code: 1 }; }
395
- // Ferma un eventuale forward tunnel ATTIVO prima di togliere la config (audit F4):
395
+ // Ferma un eventuale forward tunnel ATTIVO prima di togliere la config (da revisione):
396
396
  // senza questo il ssh resterebbe orfano — porta locale aperta verso un nodo che non
397
397
  // e' piu' in config, irraggiungibile e senza piu' uno stop pulito via CLI/API.
398
398
  let stopped = false;
@@ -51,7 +51,7 @@ function applyProbeHysteresis(key, probed, previous, now, threshold) {
51
51
  // now is simply down — reporting 'unknown' would keep a declared-dead peer
52
52
  // out of the down state forever (one probe per process, never reaching the
53
53
  // threshold unless someone keeps probing). The hysteresis only exists to
54
- // protect a measured up state from jitter, per 239e2e6.
54
+ // protect a measured up state from jitter, per una revisione precedente.
55
55
  return { ...probed, consecutiveFailures: failures };
56
56
  }
57
57
 
@@ -28,7 +28,7 @@
28
28
  // secondo scrittore — la proprieta' regge per assenza, non per controllo, e la
29
29
  // differenza va detta invece che lasciata credere.
30
30
  //
31
- // Rilievo dell'audit indipendente: una funzione provata e senza chiamanti e' il
31
+ // Rilievo della revisione indipendente: una funzione provata e senza chiamanti e' il
32
32
  // segnale piu' forte che una garanzia esista, ed e' il piu' facile da dare per
33
33
  // sbaglio.
34
34
  const fs = require('node:fs');
@@ -79,14 +79,11 @@ function readKeyFile(p) {
79
79
  + 'la chiave privata e\' leggibile oltre il proprietario, va rigenerata a mano');
80
80
  }
81
81
  // UN FILE CHE ESISTE MA NON E' USABILE FA RUMORE, NON SI SOSTITUISCE.
82
- // La prima stesura tornava `null` per uno schema sbagliato cioe' «genera
83
- // pure una chiave nuova» e LANCIAVA su un JSON corrotto, perche' il parse
84
- // stava fuori dal try. Due esiti opposti per due modi di essere illeggibile,
85
- // e quello comodo era il piu' pericoloso: rigenerare cambia l'identita' di
86
- // questo nodo, e ogni peer che aveva legato la vecchia si ritrova per sempre
87
- // con una chiave che non corrisponde. Meglio fermarsi e farlo sapere.
88
- // Rilievo dell'audit indipendente; risolto nel verso opposto a quello
89
- // proposto, perche' la coerenza va cercata sul ramo sicuro.
82
+ // Uno schema errato e un JSON corrotto sono due forme dello stesso stato non
83
+ // usabile: entrambi devono fare rumore e nessuno deve autorizzare una nuova
84
+ // chiave. Rigenerare cambia l'identita' del nodo e lascia ogni peer gia'
85
+ // associato con una chiave che non corrisponde. Il ramo sicuro si ferma e
86
+ // rende esplicita la causa senza sostituire il file.
90
87
  let parsed;
91
88
  try {
92
89
  parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
@@ -136,10 +133,10 @@ function writeKeyFile(p, privateKeyPem) {
136
133
  // ensureNodeKey: idempotente. La prima chiamata genera, le successive leggono.
137
134
  // `created` distingue i due casi per chi vuole registrarlo una volta sola.
138
135
  // `afterWriteSeam` e' un seam di prova, come `sessionExistsSeam` e `fleetSeam`
139
- // altrove: viene chiamato subito dopo la scrittura e serve a far accadere la
140
- // corsa in modo deterministico. Senza, il test della corsa e' verde per
141
- // costruzione il file esiste gia' e il percorso di CREAZIONE non viene mai
142
- // eseguito. L'ho scoperto perche' il controllo negativo non falliva.
136
+ // altrove: viene chiamato subito dopo la scrittura e rende deterministico il
137
+ // punto della corsa. Senza il seam, il test puo' osservare un file gia'
138
+ // esistente e non esercitare mai il percorso di CREAZIONE; il controllo
139
+ // negativo deve quindi fissare esplicitamente quel punto.
143
140
  function ensureNodeKey({ home, keyPath, afterWriteSeam } = {}) {
144
141
  const p = keyPath || keyPathFor(home);
145
142
  const existing = readKeyFile(p);
@@ -80,7 +80,7 @@ function isAbsPath(p) {
80
80
  // token remoto: segreto opaco, single-line, cap. Vuoto -> assente (non salvato).
81
81
  // Charset ristretto a header-safe (VCHAR + spazio/tab): il token viene iniettato
82
82
  // in `Authorization: Bearer <t>` verso l'upstream; un char fuori range farebbe
83
- // lanciare setHeader in modo sincrono (ERR_INVALID_CHAR). (hardening audit).
83
+ // lanciare setHeader in modo sincrono (ERR_INVALID_CHAR). (hardening).
84
84
  function validToken(t) {
85
85
  return typeof t === 'string' && t.length > 0 && t.length <= MAX_TOKEN_LEN
86
86
  && /^[\x20-\x7e\t]+$/.test(t);
@@ -589,7 +589,7 @@ function initStore(p) {
589
589
  }
590
590
 
591
591
  // Compatibilita' API interna: il vecchio nome non inizializza piu' a runtime.
592
- // Tenerlo strict impedisce che un call-site dimenticato reintroduca F1.
592
+ // Tenerlo strict impedisce che un call-site dimenticato reintroduca quel difetto.
593
593
  function loadOrInitStore(p) { return loadStoreStrict(p); }
594
594
 
595
595
  // --- Mutazioni pure (ritornano un nuovo store; il caller lo scrive) ---------
@@ -21,7 +21,7 @@ const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw
21
21
  const reverseFailureMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_REVERSE_FAILURE_MAX || 8);
22
22
  const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailureMaxRaw >= 1
23
23
  ? Math.min(reverseFailureMaxRaw, 32) : 8;
24
- // R19 seguito — contratto per la sonda del canale -L quando non conclude MAI:
24
+ // da revisione — contratto per la sonda del canale -L quando non conclude MAI:
25
25
  // continuare a sondare ogni 250ms e' giusto per la finestra transitoria (il
26
26
  // servizio remoto non e' ancora su dopo un restart/aggiornamento: la prossima
27
27
  // sonda e' una connessione FRESCA, si qualifica da sola appena il servizio
@@ -35,7 +35,7 @@ const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailu
35
35
  // quella soglia il canale entra in degraded, ESATTAMENTE come il fallimento
36
36
  // del forward inverso — stesso stato, stessa auto-guarigione a cadenza fissa,
37
37
  // mai un "pronto" dichiarato senza averlo verificato (sarebbe la stessa bugia
38
- // che R19 ha tolto, solo con una bandiera "non verificato" appesa sopra).
38
+ // che una revisione ha tolto, solo con una bandiera "non verificato" appesa sopra).
39
39
  const channelProbeMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_CHANNEL_PROBE_MAX || 60);
40
40
  const channelProbeMax = Number.isInteger(channelProbeMaxRaw) && channelProbeMaxRaw >= 1
41
41
  ? Math.min(channelProbeMaxRaw, 240) : 60;
@@ -112,7 +112,7 @@ function clearForwardProbe() {
112
112
  // forces OpenSSH to establish the real forward channel. Only that event may
113
113
  // advertise transport-ready or reset retry backoff.
114
114
  //
115
- // R19: connect NON è prova di canale. OpenSSH accetta la TCP sul listener
115
+ // da revisione: connect NON è prova di canale. OpenSSH accetta la TCP sul listener
116
116
  // locale SUBITO e chiede il canale al server DOPO: se il server lo nega
117
117
  // (permitopen senza quella destinazione) il socket viene CHIUSO nel giro di
118
118
  // millisecondi — bind locale riuscito, canale morto. Una finestra di grazia
@@ -159,7 +159,7 @@ function probeForwardChannels({ ports, graceMs = CHANNEL_GRACE_MS, connect = net
159
159
  });
160
160
  }
161
161
 
162
- // R19 punto 3: chi ha installato la chiave quando il pannello non esisteva è
162
+ // da revisione punto 3: chi ha installato la chiave quando il pannello non esisteva è
163
163
  // rotto e NON lo sapeva. Il prodotto lo DICE, con la riga da sostituire: le
164
164
  // destinazioni arrivano dagli `-L`, la pubblica si DERIVA dalla privata
165
165
  // indicata da `-i`. Quando non si riesce a derivarla, dice comunque COSA
@@ -359,7 +359,7 @@ function enterDegraded(diagnosis) {
359
359
  retryTimer = setTimeout(run, steadyRetryMs);
360
360
  }
361
361
 
362
- // R19 seguito, secondo difetto (2026-08-17, audit su develop@437d29f):
362
+ // secondo difetto della stessa famiglia (da revisione interna):
363
363
  // enterDegraded per il canale -L riusava la macchina del forward inverso
364
364
  // senza la sua precondizione implicita. Nel reverse failure `child` e' GIA'
365
365
  // null quando enterDegraded gira (handleFailure lo azzera PRIMA, perche' e'
@@ -369,7 +369,7 @@ function enterDegraded(diagnosis) {
369
369
  // generazione. run() faceva `child = spawn(...)` incondizionatamente: la
370
370
  // vecchia generazione, ancora viva, restava senza piu' nessuna variabile che
371
371
  // la referenzi — irraggiungibile da stop(), orfana, titolare dei suoi bind
372
- // per sempre. Misurato dall'auditor: due fake-ssh vivi dopo un degraded,
372
+ // per sempre. Misurato in revisione: due fake-ssh vivi dopo un degraded,
373
373
  // SIGTERM al supervisor ne ferma solo l'ultimo.
374
374
  //
375
375
  // La correzione non e' locale (un kill dentro enterDegraded): e' che
@@ -525,6 +525,6 @@ function acquireGeneration() {
525
525
  }
526
526
  if (require.main === module) acquireGeneration();
527
527
 
528
- // Esportate per prova diretta (R19): il main resta argv/env-driven e NON parte
528
+ // Esportate per prova diretta (da revisione): il main resta argv/env-driven e NON parte
529
529
  // al require.
530
530
  module.exports = { probeForwardChannels, refusalHint, refusalDetails, CHANNEL_GRACE_MS };
@@ -480,7 +480,7 @@ function supervisorExited(pid, timeoutMs = 2500, impl = {}) {
480
480
  // Pre-flight sincrono: l'unico modo di "surfaccare" un binario ssh assente come
481
481
  // failure ESPLICITA nel valore di ritorno (lo spawn emette 'error' asincrono, non
482
482
  // catturabile sync). spawnSyncImpl iniettabile per test deterministici.
483
- // ssh -V esiste su ogni platform supportata; ENOENT = binario mancante (audit F2).
483
+ // ssh -V esiste su ogni platform supportata; ENOENT = binario mancante (da revisione).
484
484
  function sshBinaryAvailable(sshBin, spawnSyncImpl) {
485
485
  try {
486
486
  const r = (spawnSyncImpl || spawnSync)(sshBin, ['-V'], { encoding: 'utf8' });
@@ -493,7 +493,7 @@ function sshBinaryAvailable(sshBin, spawnSyncImpl) {
493
493
  // nel logfile del tunnel; pidfile con cmd verificabile (kill anti PID-reuse).
494
494
  // spawnImpl/spawnSyncImpl iniettabili per i test (mai lancia ssh vero in suite).
495
495
  //
496
- // INVARIANTI (audit F2/F3):
496
+ // INVARIANTI (da revisione):
497
497
  // - MAI crashare: spawn throw, error async, pid mancante -> failure esplicita
498
498
  // {started:false, reason}, con cleanup di pidfile/process/log fd di nostra proprieta'.
499
499
  // - NESSUN leak del log fd aperto internamente: il padre chiude la SUA copia dopo
@@ -558,7 +558,7 @@ function startTunnel(opts) {
558
558
  };
559
559
 
560
560
  // Pre-flight: binario ssh mancante -> failure esplicita, nessuno spawn, nessun
561
- // crash (audit F2: prima restituiva started:true e poi l'event 'error' killava Node).
561
+ // crash (da revisione: prima restituiva started:true e poi l'event 'error' killava Node).
562
562
  if (!sshBinaryAvailable(sshBin, spawnSyncImpl)) {
563
563
  closeOwnedFd();
564
564
  return { started: false, reason: 'ssh binary not found', sshBin };
@@ -630,7 +630,7 @@ function startTunnel(opts) {
630
630
  if (Number.isInteger(logFd)) {
631
631
  try { fs.writeSync(logFd, `[nexuscrew] supervisor requested transport=${path.basename(sshBin)}\n`); } catch (_) {}
632
632
  }
633
- closeOwnedFd(); // copia del padre: il figlio ha la sua dup; nessun leak (audit F3)
633
+ closeOwnedFd(); // copia del padre: il figlio ha la sua dup; nessun leak (da revisione)
634
634
  return { started: true, pid, logPath, transport: sshBin };
635
635
  }
636
636
 
@@ -694,7 +694,7 @@ function readSshVersion(spawnSyncImpl) {
694
694
  } catch (_) { return null; }
695
695
  }
696
696
 
697
- // R19: la riga authorized_keys con le destinazioni EXPLICITE dei -L. Una sola
697
+ // da revisione: la riga authorized_keys con le destinazioni EXPLICITE dei -L. Una sola
698
698
  // porta quando il pannello non è (ancora) noto: nodesAdd non può conoscerla,
699
699
  // la annuncia il peer nel join — e allora la riga la emette CHI ha
700
700
  // l'informazione (pairing, supervisor). Due permitopen distinti, MAI un
@@ -715,11 +715,10 @@ function authorizedKeysLine({ remotePort, panelRemotePort, pub } = {}) {
715
715
 
716
716
  // LA PUBBLICA SI DERIVA DALLA PRIVATA, non si legge dal file accanto.
717
717
  //
718
- // Tre giri fa validavo il nome dell'algoritmo, due giri fa la struttura del
719
- // blob, un giro fa la chiedevo a `ssh-keygen -l` sul file `.pub`. Tutte e tre
720
- // le volte un audit ha trovato il caso che restava: e l'ultimo lo chiude solo
721
- // cambiando la domanda. Validare `A.pub` prova che quel file contiene UNA
722
- // chiave valida — non che sia LA chiave di `A`. Se `A.pub` e' stale, ripristinato
718
+ // Validare il nome dell'algoritmo, la struttura del blob o il risultato di
719
+ // `ssh-keygen -l` su `A.pub` prova soltanto che il file contiene UNA chiave
720
+ // valida non che sia LA chiave derivata dalla privata di `A`. Se `A.pub` e'
721
+ // stale, ripristinato
723
722
  // da un backup o sostituito, si pubblica la chiave sbagliata: l'utente
724
723
  // sostituisce la riga di A con quella di B, e al reconnect successivo A perde
725
724
  // l'accesso. Il prodotto avrebbe causato il guasto che prometteva di riparare.
@@ -733,7 +732,7 @@ function authorizedKeysLine({ remotePort, panelRemotePort, pub } = {}) {
733
732
  // batch non si puo' sbloccare) o `ssh-keygen` non c'e': senza poterla derivare
734
733
  // non si compone nessuna riga.
735
734
  // Esiti ENUMERATI della derivazione della pubblica (difetto strutturale
736
- // registrato dall'auditor: `null | stringa` comprimeva stati DIVERSI, e il
735
+ // registrato in revisione: `null | stringa` comprimeva stati DIVERSI, e il
737
736
  // livello basso componeva frasi su fatti che non conosce — meta' della 0.9.5
738
737
  // e' nata li'). Gli esiti sono DATI, senza testo per l'utente: le frasi si
739
738
  // compongono al confine UI/log, mai qui. Si aggancia a P4: una chiave cifrata
@@ -3,13 +3,13 @@
3
3
  // Stato in-memory + persistenza <dir>/asks.json 0600 (sopravvive al restart:
4
4
  // un ask aperto resta risponibile). Lo store e' PURO stato: il paste tmux e la
5
5
  // notify li orchestra la route (lib/notify/routes.js), che qui fa il ciclo
6
- // claim -> (paste) -> commit/release (F2: una sola answer puo' vincere).
6
+ // claim -> (paste) -> commit/release (da revisione: una sola answer puo' vincere).
7
7
  const path = require('node:path');
8
8
  const crypto = require('node:crypto');
9
9
  const { readJsonSafe, atomicWriteJson } = require('./persist.js');
10
10
 
11
11
  const ASKS_FILE = 'asks.json';
12
- // F5: MAX_OPEN e' un cap DURO sugli ask aperti — al cap il nuovo ask viene
12
+ // da revisione: MAX_OPEN e' un cap DURO sugli ask aperti — al cap il nuovo ask viene
13
13
  // RIFIUTATO (reason 'cap'), mai droppato uno aperto. MAX_KEEP pota solo gli
14
14
  // answered piu' vecchi dal file.
15
15
  const MAX_OPEN = 100;
@@ -72,7 +72,7 @@ function createAsksStore(opts = {}) {
72
72
  function create({ question, options, session }) {
73
73
  const v = validate({ question, options });
74
74
  if (!v.ok) return { ok: false, reason: 'invalid', error: v.error };
75
- // F5: cap duro sugli aperti — rifiuto esplicito, MAI drop di ask aperti.
75
+ // da revisione: cap duro sugli aperti — rifiuto esplicito, MAI drop di ask aperti.
76
76
  if (openCount() >= MAX_OPEN) {
77
77
  return {
78
78
  ok: false,
@@ -103,7 +103,7 @@ function createAsksStore(opts = {}) {
103
103
  .map((a) => ({ ...a, ...(a.options ? { options: a.options.slice() } : {}) }));
104
104
  }
105
105
 
106
- // --- ciclo answer F2 (audit): claim atomico open -> answering ---------------
106
+ // --- ciclo answer (da revisione): claim atomico open -> answering ---------------
107
107
  // Node e' single-threaded ma il paste e' un await: due answer concorrenti
108
108
  // superavano entrambe il check `answered` prima che una marcasse. Il claim
109
109
  // sincrono (nessun await tra check e set) fa vincere UNA sola richiesta; le
@@ -6,7 +6,7 @@ const fs = require('node:fs');
6
6
  const path = require('node:path');
7
7
  const crypto = require('node:crypto');
8
8
 
9
- // Legge un JSON (oggetto) da file segreto. Fail-closed (F4 audit): open
9
+ // Legge un JSON (oggetto) da file segreto. Fail-closed (da revisione): open
10
10
  // O_NOFOLLOW + fstat sullo STESSO fd (niente race lstat->open), e un file
11
11
  // preesistente con symlink, tipo non-regolare, owner inatteso o permessi di
12
12
  // gruppo/altri viene RIFIUTATO con errore chiaro — mai riparato in silenzio
@@ -7,7 +7,7 @@
7
7
  // Il sender e' iniettabile (webpushImpl) cosi' i test non toccano MAI la rete;
8
8
  // la chiave PRIVATA non esce da questo modulo.
9
9
  //
10
- // F7 (audit, threat model SSRF): sendNotification fa una richiesta server-side.
10
+ // da revisione (threat model SSRF): sendNotification fa una richiesta server-side.
11
11
  // Ogni endpoint e' https-only, viene risolto sia al subscribe sia immediatamente
12
12
  // prima del send, e la richiesta usa un https.Agent con lookup PINNATO agli IP
13
13
  // verificati. Cosi' un secondo lookup/DNS rebinding non puo' cambiare destinazione.
@@ -168,7 +168,7 @@ function createPushService(opts = {}) {
168
168
  if (!opts.dir) throw new Error('createPushService: dir richiesta');
169
169
  const vapidPath = path.join(opts.dir, VAPID_FILE);
170
170
  const subsPath = path.join(opts.dir, SUBS_FILE);
171
- // F3: closure iniettata dal server — in READONLY questo modulo non scrive MAI
171
+ // da revisione: closure iniettata dal server — in READONLY questo modulo non scrive MAI
172
172
  // (niente generazione VAPID, niente cleanup subscription).
173
173
  const readonly = typeof opts.readonly === 'function' ? opts.readonly : () => false;
174
174
  const lookupImpl = opts.lookupImpl || dns.promises.lookup;
@@ -191,7 +191,7 @@ function createPushService(opts = {}) {
191
191
  vapid = { publicKey: cur.publicKey, privateKey: cur.privateKey };
192
192
  return vapid;
193
193
  }
194
- // F3: la generazione e' una SCRITTURA — vietata in READONLY (503 esplicito,
194
+ // da revisione: la generazione e' una SCRITTURA — vietata in READONLY (503 esplicito,
195
195
  // niente vapid.json fantasma).
196
196
  if (readonly()) {
197
197
  const e = new Error('READONLY: chiavi VAPID assenti e non generabili (riavvia senza READONLY per il primo setup push)');
@@ -241,7 +241,7 @@ function createPushService(opts = {}) {
241
241
  function count() { return readSubs().length; }
242
242
 
243
243
  // Invia il payload a tutte le subscription. 404/410 (endpoint morto) -> la
244
- // subscription si rimuove, MA NON in READONLY (F3: riscrivere push.json e' una
244
+ // subscription si rimuove, MA NON in READONLY (da revisione: riscrivere push.json e' una
245
245
  // scrittura persistente; la notify viene comunque consegnata alle vive).
246
246
  // Nessuna subscription -> {sent:0} senza generare VAPID (resta lazy davvero).
247
247
  async function sendToAll(payload) {
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
  // Route del MCP bridge (design §2): notify, web-push, asks. Montate dentro il
3
3
  // router /api gia' dietro requireToken (server.js) — qui restano solo:
4
- // - READONLY come FLOOR (F3 audit): sono gated 403 TUTTI i mutanti — answer
4
+ // - READONLY come FLOOR (da revisione): sono gated 403 TUTTI i mutanti — answer
5
5
  // (scrittura PTY via paste), push subscribe/unsubscribe (push.json) e la
6
6
  // CREAZIONE di ask (persiste asks.json e genererebbe domande non
7
7
  // risponibili dallo stesso server). L'UNICA eccezione dichiarata e' la
@@ -9,11 +9,11 @@
9
9
  // push senza persistenza; anche il cleanup delle subscription morte e'
10
10
  // sospeso in READONLY, vedi lib/notify/push.js). Le GET restano lettura
11
11
  // pura: /push/vapid in READONLY non genera chiavi (503 se assenti).
12
- // - rate-limit (F1 audit): il campo `session` e' dichiarato dal chiamante e
12
+ // - rate-limit (da revisione): il campo `session` e' dichiarato dal chiamante e
13
13
  // NON e' un confine di sicurezza — il limite che conta e' GLOBALE per
14
14
  // principal/token (un Bearer = un'installazione); il bucket per-sessione
15
15
  // resta come fairness tra celle oneste. Stessa coppia di limiti sulla
16
- // creazione ask (F5).
16
+ // creazione ask (da revisione).
17
17
  // - validazione input strict fail-closed (schema chiuso per ogni body).
18
18
  // Il paste della risposta riusa ESATTAMENTE pasteToSession (bracketed literal,
19
19
  // niente Invio, control char rifiutati): qui si sanifica il testo PRIMA.
@@ -43,7 +43,7 @@ const MAX_BODY = 2000;
43
43
  const MAX_ANSWER = 3900;
44
44
  const MAX_REPLY_LABEL = 48;
45
45
 
46
- // Sliding window in-memory per chiave. La mappa ha un cap duro (F1): entry
46
+ // Sliding window in-memory per chiave. La mappa ha un cap duro (da revisione): entry
47
47
  // scadute potate a ogni giro, poi evizione LRU deterministica (ordine di
48
48
  // iterazione della Map = ordine di ultimo uso, re-insert ad ogni allow).
49
49
  // NB: l'evizione azzera il conteggio del bucket evitto — per questo il cap
@@ -68,7 +68,7 @@ function createRateLimiter({ max = RATE_MAX, windowMs = RATE_WINDOW_MS, maxBucke
68
68
  return { allow, size: () => hits.size };
69
69
  }
70
70
 
71
- // Coppia di limiti F1/F5: globale per token (confine di sicurezza) + per
71
+ // Coppia di limiti (da revisione): globale per token (confine di sicurezza) + per
72
72
  // sessione (fairness). Una richiesta oltre-limite consuma comunque il budget
73
73
  // globale: anche lo spam rifiutato e' attivita' del principal.
74
74
  function createSenderLimiter(rateCfg = {}) {
@@ -189,7 +189,7 @@ function notifyRoutes({
189
189
  // R31-A3: lo status e' DERIVATO dai conteggi, non dichiarato a parte.
190
190
  // `emit` e' best-effort — push fallito → 0, `ui` conta i write SSE
191
191
  // riusciti — e il dispatcher propaga SOLO l'etichetta (i conteggi
192
- // muoiono in forward(), rilievo R1/rc.14): per la cella mittente e'
192
+ // muoiono in forward(), rilievo da revisione): per la cella mittente e'
193
193
  // tutta l'informazione. Non puo' affermare una consegna che i conteggi
194
194
  // smentiscono: zero canali raggiunti → 'no-delivery'.
195
195
  const status = delivered.ui + delivered.push > 0 ? 'delivered' : 'no-delivery';
@@ -222,7 +222,7 @@ function notifyRoutes({
222
222
 
223
223
  // --- web-push --------------------------------------------------------------
224
224
  // GET lettura pura; in READONLY push.vapidPublicKey() NON genera chiavi e
225
- // segnala 503 (e.status) se assenti — F3.
225
+ // segnala 503 (e.status) se assenti — da revisione.
226
226
  r.get('/push/vapid', (_req, res) => {
227
227
  try { res.json({ publicKey: push.vapidPublicKey() }); }
228
228
  catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
@@ -232,7 +232,7 @@ function notifyRoutes({
232
232
  try {
233
233
  const sub = req.body && req.body.subscription;
234
234
  const out = await push.subscribe(sub);
235
- // F7: cap sul numero di subscription -> 429 (quota), input invalido -> 400.
235
+ // da revisione: cap sul numero di subscription -> 429 (quota), input invalido -> 400.
236
236
  if (!out.ok) return res.status(out.reason === 'cap' ? 429 : 400).json({ error: out.error });
237
237
  res.json({ subscribed: true, count: out.count });
238
238
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
@@ -247,8 +247,8 @@ function notifyRoutes({
247
247
  });
248
248
 
249
249
  // --- asks ------------------------------------------------------------------
250
- // F3: gated READONLY (mutGate) — crea stato durevole (asks.json) e domande
251
- // che lo stesso server vieterebbe di rispondere. F5: rate-limit creazione
250
+ // da revisione: gated READONLY (mutGate) — crea stato durevole (asks.json) e domande
251
+ // che lo stesso server vieterebbe di rispondere. da revisione: rate-limit creazione
252
252
  // (globale per token + per sessione) + cap duro dello store -> 429.
253
253
  r.post('/asks', mutGate, json, async (req, res) => {
254
254
  try {
@@ -294,7 +294,7 @@ function notifyRoutes({
294
294
  });
295
295
 
296
296
  // Dismiss (scarta domanda): NON cancella la riga, la marca `dismissed` (lo
297
- // storico serve). Stesso mutGate degli altri mutanti (F3: scrittura durevole).
297
+ // storico serve). Stesso mutGate degli altri mutanti (da revisione: scrittura durevole).
298
298
  // Idempotente; 404 se id inesistente; 409 se answering (claim attivo: non si
299
299
  // scarta una risposta in corso). Emette il frame per le UI aperte come fa
300
300
  // POST /asks con emitRaw: la card sparisce senza aspettare il poll.
@@ -312,7 +312,7 @@ function notifyRoutes({
312
312
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
313
313
  });
314
314
 
315
- // Answer: READONLY floor (il paste e' una scrittura PTY). F2 (audit): il
315
+ // Answer: READONLY floor (il paste e' una scrittura PTY). da revisione: il
316
316
  // ciclo e' claim atomico (open -> answering, sincrono, PRIMA dell'await del
317
317
  // paste) -> paste -> commit su successo / release su fallimento. Una sola
318
318
  // richiesta concorrente vince; le altre vedono 409. Paste fallito -> 502 e