@mmmbuto/nexuscrew 0.8.39 → 0.8.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +2 -1
  3. package/frontend/dist/assets/index-CrZBnpKn.css +32 -0
  4. package/frontend/dist/assets/index-CwZySkm2.js +93 -0
  5. package/frontend/dist/assets/index-hq-2ORFQ.js +93 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/version.json +1 -1
  8. package/lib/audio/acl.js +65 -0
  9. package/lib/audio/adapters.js +233 -0
  10. package/lib/audio/bridge-auth.js +176 -0
  11. package/lib/audio/capability.js +40 -0
  12. package/lib/audio/consent.js +64 -0
  13. package/lib/audio/dispatch.js +154 -0
  14. package/lib/audio/group-receipt.js +119 -0
  15. package/lib/audio/group-speak.js +146 -0
  16. package/lib/audio/groups.js +121 -0
  17. package/lib/audio/origin.js +128 -0
  18. package/lib/audio/queue.js +191 -0
  19. package/lib/audio/rate-limit.js +71 -0
  20. package/lib/audio/receipt.js +146 -0
  21. package/lib/audio/routes.js +374 -0
  22. package/lib/audio/speak.js +125 -0
  23. package/lib/cli/doctor.js +37 -0
  24. package/lib/config.js +7 -0
  25. package/lib/fleet/builtin.js +2 -1
  26. package/lib/fleet/launch.js +16 -0
  27. package/lib/fleet/managed.js +53 -2
  28. package/lib/fleet/provider.js +6 -0
  29. package/lib/fleet/runtime.js +14 -1
  30. package/lib/mcp/server.js +27 -3
  31. package/lib/mcp/tools.js +142 -0
  32. package/lib/nodes/tunnel-supervisor.js +33 -7
  33. package/lib/nodes/tunnel.js +21 -0
  34. package/lib/proxy/federation.js +41 -11
  35. package/lib/proxy/hop-proof.js +50 -0
  36. package/lib/server.js +97 -3
  37. package/lib/settings/routes.js +150 -4
  38. package/lib/tmux/lifecycle.js +24 -3
  39. package/package.json +1 -1
  40. package/skills/nexuscrew-agent/SKILL.md +30 -1
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+ // lib/proxy/hop-proof.js — prova che una richiesta arrivata su /api e' l'ULTIMO
3
+ // hop di una route federata generata da QUESTO processo, non un POST diretto di
4
+ // un client locale che ha semplicemente scritto gli header a mano.
5
+ //
6
+ // Perche' serve. `controlledVisited()` lega gia' `x-nexuscrew-visited` al peer
7
+ // autenticato dal suo acceptToken, ma l'ultimo hop entra in `/api/<resource>`
8
+ // con il Bearer LOCALE: a quel punto l'handler non distingue piu' un inbound
9
+ // federato da una richiesta diretta di chiunque possieda il token della UI.
10
+ // `cleanHeaders()` cancella gia' `x-nexuscrew-{route,visited,hop}` da ogni
11
+ // header in ingresso, quindi il canale e' riservato: basta renderlo non
12
+ // riproducibile. La firma usa un segreto casuale per-processo, mai su disco,
13
+ // mai esposto da un'API: un client locale non puo' calcolarla.
14
+ //
15
+ // Il payload firmato include metodo e path oltre alla catena visited, cosi' una
16
+ // prova non e' trasportabile su un'altra risorsa o su un altro verbo.
17
+ const crypto = require('node:crypto');
18
+
19
+ const HOP_HEADER = 'x-nexuscrew-hop';
20
+ const PREFIX = 'NEXUSCREW-HOP-V1';
21
+
22
+ function createHopSecret() {
23
+ return crypto.randomBytes(32);
24
+ }
25
+
26
+ function canonicalHop({ method, path: reqPath, visited }) {
27
+ const chain = Array.isArray(visited) ? visited.join(',') : String(visited || '');
28
+ return `${PREFIX}\n${String(method || '').toUpperCase()}\n${String(reqPath || '')}\n${chain}`;
29
+ }
30
+
31
+ // signHop(): hex HMAC-SHA256. Ritorna null senza segreto, cosi' un server che
32
+ // non ha inizializzato la prova non emette un header vuoto interpretabile.
33
+ function signHop(secret, parts) {
34
+ if (!secret) return null;
35
+ return crypto.createHmac('sha256', secret).update(canonicalHop(parts)).digest('hex');
36
+ }
37
+
38
+ // verifyHop(): confronto timing-safe. Fail-closed su qualunque anomalia
39
+ // (segreto assente, header assente, lunghezza diversa, hex non valido).
40
+ function verifyHop(secret, parts, proof) {
41
+ if (!secret || typeof proof !== 'string' || !/^[a-f0-9]{64}$/i.test(proof)) return false;
42
+ const expected = signHop(secret, parts);
43
+ if (!expected) return false;
44
+ const a = Buffer.from(expected, 'hex');
45
+ const b = Buffer.from(proof.toLowerCase(), 'hex');
46
+ if (a.length !== b.length) return false;
47
+ return crypto.timingSafeEqual(a, b);
48
+ }
49
+
50
+ module.exports = { createHopSecret, signHop, verifyHop, canonicalHop, HOP_HEADER };
package/lib/server.js CHANGED
@@ -32,6 +32,17 @@ const nodesInventory = require('./nodes/inventory.js');
32
32
  const topologyCache = require('./nodes/topology-cache.js');
33
33
  const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
34
34
  const federation = require('./proxy/federation.js');
35
+ const { audioRoutes } = require('./audio/routes.js');
36
+ const { isConsent: isAudioConsent } = require('./audio/consent.js');
37
+ const { createOriginResolver } = require('./audio/origin.js');
38
+ const { createAudioAcl } = require('./audio/acl.js');
39
+ const { createDispatcher } = require('./audio/dispatch.js');
40
+ const { createSpeakQueue } = require('./audio/queue.js');
41
+ const { createReceiptStore } = require('./audio/receipt.js');
42
+ const audioAdapters = require('./audio/adapters.js');
43
+ const audioGroups = require('./audio/groups.js');
44
+ const { bridgeSecretPath, loadOrCreateBridgeSecret, createNonceCache } = require('./audio/bridge-auth.js');
45
+ const { createHopSecret } = require('./proxy/hop-proof.js');
35
46
  const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
36
47
  const decksStore = require('./decks/store.js');
37
48
  const { decksRoutes } = require('./decks/routes.js');
@@ -133,6 +144,22 @@ function createServer(opts = {}) {
133
144
  const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
134
145
  const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
135
146
  const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
147
+ const audioHome = cfg.home || os.homedir();
148
+ // Stato audio ancorato alla directory del token (come notifyDir): server,
149
+ // Settings e bridge MCP devono guardare gli stessi file anche quando un test
150
+ // isola l'istanza fuori dalla home reale.
151
+ const audioCfg = {
152
+ home: audioHome,
153
+ tokenPath: cfg.tokenPath,
154
+ audioConsentPath: cfg.audioConsentPath,
155
+ audioGroupsPath: cfg.audioGroupsPath,
156
+ audioBridgeSecretPath: cfg.audioBridgeSecretPath || path.join(notifyDir, 'audio-bridge.key'),
157
+ };
158
+ // Segreto per-processo della prova di hop federata: vive solo in memoria, non
159
+ // e' su disco e nessuna API lo espone. Serve a distinguere l'ultimo hop di una
160
+ // route federata da un POST diretto di chi possiede il token della UI.
161
+ const hopSecret = createHopSecret();
162
+ const audioNonceCache = createNonceCache();
136
163
  function resolveNode(name) {
137
164
  const st = nodesStore.loadStore(nodesPath);
138
165
  if (!st) return null;
@@ -246,7 +273,10 @@ function createServer(opts = {}) {
246
273
  try {
247
274
  const { name, cwd, preset } = req.body || {};
248
275
  await createSession(cfg.tmuxBin, { name, cwd, preset },
249
- { home: os.homedir(), presets: cfg.sessionPresets, ensureProtection: ensureTmuxProtection });
276
+ {
277
+ home: os.homedir(), presets: cfg.sessionPresets, ensureProtection: ensureTmuxProtection,
278
+ alternateScreen: cfg.alternateScreen, log: cfg.log,
279
+ });
250
280
  res.status(201).json({ created: true, name });
251
281
  } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
252
282
  });
@@ -299,6 +329,64 @@ function createServer(opts = {}) {
299
329
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
300
330
  }));
301
331
  api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
332
+ // Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
333
+ // store, la stessa fonte usata da /api/cells e /api/peers. Lo stato Fleet e'
334
+ // asincrono e va atteso: leggerlo come se fosse sincrono lascerebbe la
335
+ // risoluzione dell'origine sempre vuota e la feature morta senza che un test
336
+ // sul router se ne accorga.
337
+ const audioNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
338
+ // Seam di test per l'adapter: permette di esercitare il percorso completo
339
+ // (route -> gate -> coda -> receipt) sul server REALE senza emettere suono.
340
+ // In produzione resta il probe locale.
341
+ const audioAdapter = cfg.audioAdapterSeam
342
+ || audioAdapters.createAdapter(audioAdapters.detectAdapter({ env: cfg.env || process.env }));
343
+ const audioReceipts = createReceiptStore();
344
+ const audioQueue = createSpeakQueue({
345
+ adapter: audioAdapter,
346
+ // La coda e' l'unica a sapere com'e' finito davvero un enunciato: e' lei ad
347
+ // aggiornare il receipt da `accepted` a spoken/refused/unknown.
348
+ onStatus: (utteranceId, status, reason) => { try { audioReceipts.update(utteranceId, status, reason); } catch (_) {} },
349
+ });
350
+ api.use('/audio', audioRoutes({
351
+ readonly: proxyReadonly,
352
+ localNodeId: audioNodeId,
353
+ receiptStore: audioReceipts,
354
+ adapter: audioAdapter,
355
+ queue: audioQueue,
356
+ acl: createAudioAcl({ nodesPath }),
357
+ consent: () => { try { return isAudioConsent(audioCfg, audioHome); } catch (_) { return false; } },
358
+ originResolver: createOriginResolver({
359
+ localNodeId: audioNodeId,
360
+ // Celle Fleet ATTIVE in questo momento, attese davvero.
361
+ activeCells: async () => {
362
+ const fleet = await fleetP;
363
+ if (!fleet || fleet.available !== true || typeof fleet.status !== 'function') return [];
364
+ const st = await fleet.status();
365
+ return Array.isArray(st && st.cells) ? st.cells.map((c) => ({
366
+ cell: c && c.cell, tmuxSession: c && c.tmuxSession,
367
+ active: c && c.active === true && c.tmux !== false,
368
+ })) : [];
369
+ },
370
+ bridgeSecret: () => { try { return loadOrCreateBridgeSecret(bridgeSecretPath(audioCfg, audioHome)); } catch (_) { return null; } },
371
+ hopSecret: () => hopSecret,
372
+ nonceCache: audioNonceCache,
373
+ }),
374
+ dispatcher: createDispatcher({
375
+ localNodeId: audioNodeId,
376
+ peers: async () => {
377
+ const st = nodesStore.loadStore(nodesPath);
378
+ if (!st) return [];
379
+ const topology = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch });
380
+ return nodesInventory.buildInventory({
381
+ direct: nodesStore.redactStore(st).nodes,
382
+ topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
383
+ });
384
+ },
385
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
386
+ localToken: () => tokenHolder.value,
387
+ }),
388
+ getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
389
+ }));
302
390
  api.use('/cells', cellsRoutes({
303
391
  fleetP,
304
392
  instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
@@ -359,7 +447,12 @@ function createServer(opts = {}) {
359
447
  // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
360
448
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
361
449
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
362
- api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort }));
450
+ api.use('/settings', settingsRoutes({
451
+ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort,
452
+ // Stesso adapter/coda dell'API Audio: Settings puo' fare solo la prova
453
+ // locale a frase fissa e lo stop sovrano, mai una seconda sintesi parallela.
454
+ audio: { adapter: audioAdapter, queue: audioQueue },
455
+ }));
363
456
  api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
364
457
  api.get('/topology', async (_req, res) => {
365
458
  try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
@@ -367,6 +460,7 @@ function createServer(opts = {}) {
367
460
  });
368
461
  api.use('/route', federation.localRouter({
369
462
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
463
+ hopSecret: () => hopSecret,
370
464
  }));
371
465
  api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
372
466
  api.post('/voice/transcribe',
@@ -392,7 +486,7 @@ function createServer(opts = {}) {
392
486
  app.use('/api', api);
393
487
  app.use('/federation', federation.peerRouter({
394
488
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
395
- fetchImpl: healthFetch,
489
+ fetchImpl: healthFetch, hopSecret: () => hopSecret,
396
490
  roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
397
491
  }));
398
492
 
@@ -45,6 +45,10 @@ const express = require('express');
45
45
 
46
46
  const nodesStore = require('../nodes/store.js');
47
47
  const nodeAliases = require('../nodes/aliases.js');
48
+ const { setConsent: setAudioConsent, isConsent: isAudioConsent } = require('../audio/consent.js');
49
+ const { describeCapability } = require('../audio/capability.js');
50
+ const audioAdapters = require('../audio/adapters.js');
51
+ const audioGroups = require('../audio/groups.js');
48
52
  const nodesCmds = require('../nodes/commands.js');
49
53
  const nodesTunnel = require('../nodes/tunnel.js');
50
54
  const peering = require('../nodes/peering.js');
@@ -63,10 +67,11 @@ const { createPairHandler } = require('./pairing-coordinator.js');
63
67
  const { publicPeeringRoutes } = require('./public-peering-routes.js');
64
68
 
65
69
  // Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
66
- const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
70
+ const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate', 'alternateScreen']);
67
71
  const ROLE_KEYS = new Set(['client', 'node']);
68
72
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
69
73
  const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
74
+ const LOCAL_AUDIO_TEST_TEXT = 'NexusCrew audio test.';
70
75
 
71
76
  // Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
72
77
  // NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
@@ -165,6 +170,10 @@ function settingsRoutes(deps = {}) {
165
170
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
166
171
  const platform = seams.platform || detectPlatform();
167
172
  const runtimePort = typeof deps.runtimePort === 'function' ? deps.runtimePort : () => cfg.port;
173
+ // Il server passa qui l'adapter e la coda REALI. Il fallback serve solo ai
174
+ // test unitari di Settings costruiti senza il server: non deve creare una
175
+ // seconda coda che dichiara un test riuscito ma non parla sul nodo vero.
176
+ const audioService = deps.audio && typeof deps.audio === 'object' ? deps.audio : null;
168
177
 
169
178
  const r = express.Router();
170
179
  r.use(express.json({ limit: '8kb' }));
@@ -233,6 +242,9 @@ function settingsRoutes(deps = {}) {
233
242
  service,
234
243
  version: VERSION,
235
244
  autoUpdate: updateStatus ? updateStatus.enabled : fileCfg.autoUpdate !== false,
245
+ // Valore effettivo: l'env mantiene la precedenza sul file e il runtime
246
+ // Fleet legge lo stesso oggetto cfg alla prossima creazione di cella.
247
+ alternateScreen: cfg.alternateScreen === true,
236
248
  // Nome dispositivo proposto per i form di pairing (etichetta umana, non
237
249
  // lo slug). La UI lo precompila e lascia editing libero.
238
250
  deviceName,
@@ -254,10 +266,10 @@ function settingsRoutes(deps = {}) {
254
266
  return send(res, 400, { error: 'body deve essere un oggetto JSON' });
255
267
  }
256
268
  for (const k of Object.keys(b)) {
257
- if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate)` });
269
+ if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate, alternateScreen)` });
258
270
  }
259
271
  if (Object.keys(b).length === 0) {
260
- return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate)' });
272
+ return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate, alternateScreen)' });
261
273
  }
262
274
  if (b.roles !== undefined) {
263
275
  if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
@@ -287,6 +299,9 @@ function settingsRoutes(deps = {}) {
287
299
  if (b.autoUpdate !== undefined && typeof b.autoUpdate !== 'boolean') {
288
300
  return send(res, 400, { error: 'autoUpdate deve essere boolean' });
289
301
  }
302
+ if (b.alternateScreen !== undefined && typeof b.alternateScreen !== 'boolean') {
303
+ return send(res, 400, { error: 'alternateScreen deve essere boolean' });
304
+ }
290
305
 
291
306
  // merge sul config esistente (preserva le chiavi non gestite qui)
292
307
  const { cfg: current } = readConfigFile(configPath);
@@ -298,14 +313,23 @@ function settingsRoutes(deps = {}) {
298
313
  if (b.port !== undefined) next.port = b.port;
299
314
  if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
300
315
  if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
316
+ if (b.alternateScreen !== undefined) next.alternateScreen = b.alternateScreen;
301
317
  atomicWriteConfig(configPath, next);
302
318
  if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
303
319
  updater.setEnabled(b.autoUpdate);
304
320
  }
321
+ // Vale dalla prossima fleet up, mai retroattivamente; rispetta l'env che
322
+ // conserva la precedenza sul file per tutta la vita del processo.
323
+ if (b.alternateScreen !== undefined && process.env.NEXUSCREW_ALTERNATE_SCREEN === undefined) {
324
+ cfg.alternateScreen = b.alternateScreen;
325
+ }
305
326
 
306
327
  const out = {
307
328
  saved: true,
308
- config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone, autoUpdate: next.autoUpdate !== false },
329
+ config: {
330
+ roles: next.roles, port: next.port, wizardDone: next.wizardDone,
331
+ autoUpdate: next.autoUpdate !== false, alternateScreen: next.alternateScreen === true,
332
+ },
309
333
  };
310
334
  // Il server legge la porta SOLO allo startup: il cambio vale al prossimo
311
335
  // restart (contratto dichiarato nella risposta, la UI avvisa).
@@ -650,6 +674,23 @@ function settingsRoutes(deps = {}) {
650
674
  // the store/UI says private. Same-state OFF revokes first, then enters
651
675
  // the spec-aware start path without rewriting nodes.json.
652
676
  if (body.shared) {
677
+ // A same-state Share ON may target a supervisor stuck in "degraded"
678
+ // (reverse channel retrying on a fixed cadence after the initial
679
+ // budget). Accelerate ONLY a degraded supervisor that readTunnelState
680
+ // confirms we own (owned live pidfile + sidecar): force a local
681
+ // restart so the -R is retried immediately instead of waiting for the
682
+ // steady retry. A healthy tunnel stays idempotent, and we never revoke
683
+ // on the hub nor change the persisted shared intent in this branch.
684
+ const liveState = nodesTunnel.readTunnelState(home, name);
685
+ if (liveState && liveState.phase === 'degraded') {
686
+ // Reuse the normal restart transaction: it fails closed on a local
687
+ // spawn error and waits for the authenticated -L health probe before
688
+ // publishing Share again. A process restart alone is never proof
689
+ // that the reverse channel is ready.
690
+ await ensureLocal({ restart: true });
691
+ await notifyHub(true);
692
+ return send(res, 200, { name, shared: true, unchanged: true, accelerated: true });
693
+ }
653
694
  await ensureLocal();
654
695
  await notifyHub(true);
655
696
  return send(res, 200, { name, shared: true, unchanged: true, reconciled: true });
@@ -709,6 +750,111 @@ function settingsRoutes(deps = {}) {
709
750
  if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
710
751
  return applyNodeEdit(res, name, { label });
711
752
  });
753
+ // Consenso audio: proprieta' del nodo LOCALE, cioe' della macchina che
754
+ // suonerebbe, non di un record peer. Vive in un file dedicato e si muta solo
755
+ // da qui: `/audio/consent` non compare nella whitelist federation, quindi
756
+ // nessun peer puo' concedersi da remoto il permesso di far parlare un
757
+ // computer altrui. E' il terzo asse, separato da `shared` (pubblicazione) e da
758
+ // `visibility` (routing): nessuno dei due autorizza un suono fisico.
759
+ const audioCfg = {
760
+ home,
761
+ tokenPath: cfg && cfg.tokenPath,
762
+ audioConsentPath: cfg && cfg.audioConsentPath,
763
+ audioGroupsPath: cfg && cfg.audioGroupsPath,
764
+ };
765
+ const localAudioAdapter = () => {
766
+ if (audioService) return audioService.adapter || null;
767
+ return audioAdapters.createAdapter(audioAdapters.detectAdapter({}));
768
+ };
769
+ const localAudioQueue = () => (audioService ? audioService.queue || null : null);
770
+ const localAudioCapability = () => {
771
+ const consent = isAudioConsent(audioCfg, home) === true;
772
+ return describeCapability({ adapter: localAudioAdapter(), consent });
773
+ };
774
+ const emptyBody = (body) => body === undefined
775
+ || (body && typeof body === 'object' && !Array.isArray(body) && Object.keys(body).length === 0);
776
+ r.get('/audio', (_req, res) => {
777
+ try {
778
+ // Capability redatta: id logico dell'adapter e limiti dichiarati, mai il
779
+ // path del binario e mai l'elenco delle voci di sistema.
780
+ return send(res, 200, localAudioCapability());
781
+ } catch (_) {
782
+ return send(res, 500, { error: 'stato audio non leggibile' });
783
+ }
784
+ });
785
+ r.patch('/audio/consent', mutGate, (req, res) => {
786
+ const consent = req.body && req.body.consent;
787
+ if (typeof consent !== 'boolean') {
788
+ return send(res, 400, { error: 'body non valido: atteso {consent:boolean}' });
789
+ }
790
+ try {
791
+ setAudioConsent(audioCfg, consent, home);
792
+ return send(res, 200, localAudioCapability());
793
+ } catch (_) {
794
+ return send(res, 500, { error: 'scrittura consenso audio non riuscita' });
795
+ }
796
+ });
797
+ // Prova esplicita dal pannello Settings. Non e' `nc_speak`: non accetta testo,
798
+ // target, cella o origine dal browser e non e' inoltrabile via federazione.
799
+ // Usa solo una frase fissa, il consenso del nodo e la sua coda reale; un 200
800
+ // descrive l'esito onesto dell'azione, NON prova che qualcuno abbia sentito.
801
+ r.post('/audio/test', mutGate, (req, res) => {
802
+ if (!emptyBody(req.body)) return send(res, 400, { error: 'body non valido: nessun campo ammesso' });
803
+ try {
804
+ const capability = localAudioCapability();
805
+ if (capability.consent !== true) return send(res, 200, { status: 'refused', reason: 'consent' });
806
+ const queue = localAudioQueue();
807
+ if (capability.installed !== true || !queue || typeof queue.enqueue !== 'function') {
808
+ return send(res, 200, { status: 'refused', reason: 'no-adapter' });
809
+ }
810
+ const result = queue.enqueue({
811
+ utteranceId: `settings-${crypto.randomUUID()}`,
812
+ text: LOCAL_AUDIO_TEST_TEXT,
813
+ urgency: 'normal',
814
+ });
815
+ return send(res, 200, {
816
+ status: result && result.status === 'accepted' ? 'accepted' : 'refused',
817
+ ...(result && result.reason ? { reason: String(result.reason).slice(0, 64) } : {}),
818
+ });
819
+ } catch (_) {
820
+ return send(res, 500, { error: 'prova audio locale non riuscita' });
821
+ }
822
+ });
823
+ // Stop locale sovrano: resta disponibile anche in READONLY. Non richiede rete,
824
+ // origin MCP o receipt, perche' zittire il dispositivo non deve dipendere da
825
+ // chi l'aveva fatto parlare. Ferma tutti i test/enunciati ancora in coda.
826
+ r.post('/audio/stop', (req, res) => {
827
+ if (!emptyBody(req.body)) return send(res, 400, { error: 'body non valido: nessun campo ammesso' });
828
+ try {
829
+ const queue = localAudioQueue();
830
+ const stopped = !!(queue && typeof queue.stopAll === 'function' && queue.stopAll());
831
+ return send(res, 200, { status: 'accepted', stopped });
832
+ } catch (_) {
833
+ return send(res, 500, { error: 'stop audio locale non riuscito' });
834
+ }
835
+ });
836
+ // Gruppi dell'ORIGINE: configurazione locale, separata dal consenso di ogni
837
+ // endpoint. Non e' nella whitelist federation e una lista non puo' far
838
+ // parlare un nodo senza il suo consenso/liveness al momento della consegna.
839
+ r.get('/audio/groups', (_req, res) => {
840
+ try { return send(res, 200, { groups: audioGroups.listGroups(audioCfg, home) }); }
841
+ catch (_) { return send(res, 500, { error: 'gruppi audio non leggibili' }); }
842
+ });
843
+ r.put('/audio/groups/:name', mutGate, (req, res) => {
844
+ const body = req.body;
845
+ if (!body || typeof body !== 'object' || Array.isArray(body)
846
+ || Object.keys(body).some((key) => !['targets', 'mode'].includes(key))) {
847
+ return send(res, 400, { error: 'body non valido: attesi targets e mode' });
848
+ }
849
+ try { return send(res, 200, { group: audioGroups.saveGroup(audioCfg, String(req.params.name || ''), body, home) }); }
850
+ catch (_) { return send(res, 400, { error: 'gruppo audio non valido' }); }
851
+ });
852
+ r.delete('/audio/groups/:name', mutGate, (req, res) => {
853
+ try {
854
+ const removed = audioGroups.removeGroup(audioCfg, String(req.params.name || ''), home);
855
+ return removed ? send(res, 200, { removed: true }) : send(res, 404, { error: 'gruppo audio non trovato' });
856
+ } catch (_) { return send(res, 400, { error: 'nome gruppo audio non valido' }); }
857
+ });
712
858
 
713
859
  // Viewer-local aliases for routed nodes. These routes are local-only and do
714
860
  // not mutate the peer identity, route, label, or federated topology.
@@ -2,6 +2,7 @@
2
2
  const fs = require('node:fs');
3
3
  const path = require('node:path');
4
4
  const { execFile } = require('node:child_process');
5
+ const { alternateScreenArgs, minimalEnv, tmuxExec } = require('../fleet/launch.js');
5
6
 
6
7
  // Preset allowlistati (audit F1): il client sceglie un NOME, mai un comando.
7
8
  // Estendibili da config.json `sessionPresets` (name -> array argv di stringhe).
@@ -52,9 +53,25 @@ function buildCreateArgs(name, realCwd, preset, extraPresets) {
52
53
 
53
54
  function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
54
55
 
55
- async function createSession(tmuxBin, { name, cwd, preset }, { home, presets, ensureProtection } = {}) {
56
+ // Best effort per la sola sessione PWA appena creata. Riusa il builder Fleet:
57
+ // target exact-match, opzione window-local e hook per le finestre successive.
58
+ // Un tmux che rifiuta l'opzione non deve mai trasformare una creazione riuscita
59
+ // in un errore HTTP; nessuna opzione globale viene emessa.
60
+ async function configureAlternateScreen(tmuxBin, name, alternateScreen, log) {
61
+ const steps = alternateScreenArgs(name, alternateScreen);
62
+ if (!steps) return;
63
+ const warn = typeof log === 'function' ? log : console.warn;
64
+ for (const args of steps) {
65
+ const configured = await tmuxExec(tmuxBin, args, { env: minimalEnv(), timeoutMs: 2000 });
66
+ if (configured.err) warn(`session alternate-screen setup failed for ${name}; continuing`);
67
+ }
68
+ }
69
+
70
+ async function createSession(tmuxBin, { name, cwd, preset }, {
71
+ home, presets, ensureProtection, alternateScreen, log,
72
+ } = {}) {
56
73
  if (typeof ensureProtection === 'function') await ensureProtection();
57
- return new Promise((resolve, reject) => {
74
+ await new Promise((resolve, reject) => {
58
75
  if (!validSessionName(name)) return reject(httpError(400, 'nome sessione non valido'));
59
76
  // Il namespace cloud-* e' delle celle fleet (audit finale #1): una generica
60
77
  // con quel prefisso occuperebbe il binding per-nome e sarebbe poi 409 al kill.
@@ -71,6 +88,7 @@ async function createSession(tmuxBin, { name, cwd, preset }, { home, presets, en
71
88
  resolve();
72
89
  });
73
90
  });
91
+ await configureAlternateScreen(tmuxBin, name, alternateScreen, log);
74
92
  }
75
93
 
76
94
  async function killSession(tmuxBin, name, { ensureProtection } = {}) {
@@ -86,4 +104,7 @@ async function killSession(tmuxBin, name, { ensureProtection } = {}) {
86
104
  });
87
105
  }
88
106
 
89
- module.exports = { PRESETS, validSessionName, resolveCwd, isProtectedSession, buildCreateArgs, createSession, killSession };
107
+ module.exports = {
108
+ PRESETS, validSessionName, resolveCwd, isProtectedSession, buildCreateArgs,
109
+ configureAlternateScreen, createSession, killSession,
110
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.39",
3
+ "version": "0.8.41",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nexuscrew-agent
3
- description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime or deck context, list authorized Fleet cells, message an exact active cell, read its inbox, deliver a file, or recover from local tmux messages that remain unsubmitted or garbled. Prefer nc_notify, nc_ask, nc_status, nc_deck, nc_cells, nc_send_cell, nc_inbox, and nc_send_file; use bundled tmux/file helpers only as a declared compatibility fallback.
3
+ description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime or deck context, list authorized Fleet cells, message an exact active cell, read its inbox, deliver a file, recover from local tmux messages that remain unsubmitted or garbled, or when a drag in the web terminal does not scroll a full-screen TUI. Prefer nc_notify, nc_ask, nc_status, nc_deck, nc_cells, nc_send_cell, nc_inbox, and nc_send_file; use bundled tmux/file helpers only as a declared compatibility fallback.
4
4
  ---
5
5
 
6
6
  # NexusCrew Agent I/O
@@ -91,6 +91,33 @@ It does: `load-buffer` → `paste-buffer -p` (bracketed paste) → burst-flush (
91
91
  tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
92
92
  ```
93
93
 
94
+ ## Terminal scrolling (NexusCrew-managed tmux)
95
+
96
+ A drag or wheel in the NexusCrew web terminal browses the tmux history through
97
+ copy-mode. That works only while the pane is on the normal screen. A full-screen
98
+ TUI — Claude Code, Codex, Agy, `vim`, `less`, `htop` — renders in the terminal's
99
+ **alternate buffer**, which has no scrollback: copy-mode has nothing to scroll, so
100
+ the gesture does nothing. On a phone, where the drag is the only scroll gesture,
101
+ the terminal looks frozen.
102
+
103
+ NexusCrew applies `alternate-screen off` **per session** when it creates a
104
+ managed Fleet session or a PWA session. No global tmux option or `~/.tmux.conf` edit is needed:
105
+ tmux then ignores `smcup`/`rmcup` for that session, TUI output stays on the
106
+ normal screen, the transcript flows into tmux history, and drag/wheel scrolling
107
+ work. The setting is also applied to windows created later in that session.
108
+
109
+ - It applies only to future NexusCrew-created sessions. A running or unmanaged
110
+ pane remains unchanged; never restart a Fleet cell just to change it.
111
+ - To opt out, set `alternateScreen: true` in NexusCrew's local `config.json` or
112
+ start the service with `NEXUSCREW_ALTERNATE_SCREEN=1`.
113
+ - Verify on a pane that is running a TUI:
114
+ `tmux display-message -p -t '=<session>:' '#{alternate_on}'` must print `0`.
115
+ - Trade-off: after quitting `vim`/`less`/`htop` the last screen stays on display
116
+ instead of being restored.
117
+ - Full-screen redraws consume history. Keep a generous user-owned
118
+ `history-limit` (100000 is a good default); `nexuscrew doctor` warns below
119
+ 10000 and suggests `set -g history-limit 100000`, but never writes it.
120
+
94
121
  ## Quick reference
95
122
 
96
123
  | Goal | Do this |
@@ -106,6 +133,7 @@ tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
106
133
  | Send a prompt/command to a session | `nc-send <session> "text"` |
107
134
  | Queue text without running it | `nc-send <session> --no-submit "text"` |
108
135
  | Confirm a send worked | `tmux capture-pane -t <session> -p | tail` |
136
+ | Make a new managed terminal scrollable on a full-screen TUI | NexusCrew default; verify `#{alternate_on}` is `0` |
109
137
 
110
138
  ## Common mistakes
111
139
 
@@ -116,3 +144,4 @@ tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
116
144
  - **Delivering to a guessed session name** → file lands in an orphan folder with no badge. Use `nc-deliver` (it reads the real session).
117
145
  - **Sending to an ambiguous cell name** → call `nc_cells` and use the full owner-qualified ID.
118
146
  - **Calling `submitted` a completed task** → it is only a transport receipt; require an explicit result callback.
147
+ - **Treating a dead scroll gesture as a web-terminal bug** → the pane is in the alternate buffer. Check whether it predates the NexusCrew setting or opted out with `alternateScreen:true`; never send raw page keys to a TUI to work around it.