@mmmbuto/nexuscrew 0.8.40 → 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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-CwZySkm2.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-5PMZjYKR.css">
14
+ <script type="module" crossorigin src="/assets/index-hq-2ORFQ.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CrZBnpKn.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.40"}
1
+ {"version":"0.8.41"}
package/lib/cli/doctor.js CHANGED
@@ -15,6 +15,7 @@ const { fleetInstallPath } = require('./fleet-service.js');
15
15
  const { resolvePaths } = require('./url.js');
16
16
  const { commandExists } = require('./path.js');
17
17
  const { loadDefinitions } = require('../fleet/definitions.js');
18
+ const { loadConfig } = require('../config.js');
18
19
  const {
19
20
  termuxRuntimePaths, trustedTermuxPreload, TERMUX_EXEC_BASENAME_RE,
20
21
  } = require('../runtime/env.js');
@@ -426,6 +427,39 @@ function checkFleetDefinitions(home, fleetDefsPath, enabled = true) {
426
427
  };
427
428
  }
428
429
 
430
+ // Read-only check: alternate-screen is applied per managed session, while the
431
+ // history limit remains an operator-owned tmux setting. Do not write ~/.tmux.conf
432
+ // or change a live server from doctor; only recommend a sufficient value.
433
+ function checkAlternateScreenHistory(alternateScreen, execImpl, tmuxBin) {
434
+ if (alternateScreen === true) {
435
+ return { name: 'Fleet alternate-screen history', ok: true, detail: 'alternate-screen standard attivo' };
436
+ }
437
+ let raw = '';
438
+ try {
439
+ raw = String(execImpl(tmuxBin || 'tmux', ['show-options', '-g', 'history-limit'], { encoding: 'utf8' }) || '');
440
+ } catch (_) {
441
+ return {
442
+ name: 'Fleet alternate-screen history', ok: true, warn: true,
443
+ detail: 'history-limit non verificabile; con alternateScreen off configura set -g history-limit 100000 in ~/.tmux.conf',
444
+ };
445
+ }
446
+ const match = raw.match(/(?:^|\n)history-limit\s+(\d+)\b/);
447
+ if (!match) {
448
+ return {
449
+ name: 'Fleet alternate-screen history', ok: true, warn: true,
450
+ detail: 'history-limit non leggibile; con alternateScreen off configura set -g history-limit 100000 in ~/.tmux.conf',
451
+ };
452
+ }
453
+ const limit = Number(match[1]);
454
+ if (limit < 10000) {
455
+ return {
456
+ name: 'Fleet alternate-screen history', ok: true, warn: true,
457
+ detail: `history-limit ${limit} (<10000) con alternateScreen off; configura set -g history-limit 100000 in ~/.tmux.conf`,
458
+ };
459
+ }
460
+ return { name: 'Fleet alternate-screen history', ok: true, detail: `history-limit ${limit}` };
461
+ }
462
+
429
463
  // Esegue tutti i check. Seam iniettabili per test (platform, home, execImpl, ptyLoad).
430
464
  function doctor(opts = {}) {
431
465
  const platform = opts.platform || detectPlatform();
@@ -454,6 +488,8 @@ function doctor(opts = {}) {
454
488
  checkTmuxSurvival(platform, execImpl),
455
489
  checkTokenPerms(tokenPath),
456
490
  checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
491
+ checkAlternateScreenHistory(opts.alternateScreen !== undefined
492
+ ? opts.alternateScreen : loadConfig().alternateScreen, execImpl, opts.tmuxBin),
457
493
  checkTermuxExec(opts.env, { platform, home }),
458
494
  checkSshClient(existsImpl),
459
495
  checkAutossh(existsImpl),
@@ -477,6 +513,7 @@ module.exports = {
477
513
  checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
478
514
  checkMacServiceWorkingDirectory,
479
515
  checkFleetDefinitions,
516
+ checkAlternateScreenHistory,
480
517
  checkServiceWorkingDirectory,
481
518
  checkFleetServiceWorkingDirectory,
482
519
  checkTmuxSurvival, checkUserLinger,
package/lib/config.js CHANGED
@@ -22,6 +22,9 @@ function baseDefaults() {
22
22
  // Shared-server safety is operational hardening, not a same-UID security
23
23
  // boundary. Disable only when the operator deliberately owns tmux policy.
24
24
  protectSharedTmuxServer: true,
25
+ // Fleet sessions stay on the normal screen by default so their transcript
26
+ // enters tmux history and remains scrollable from the web terminal.
27
+ alternateScreen: false,
25
28
  readonlyDefault: false,
26
29
  // Etichetta neutra usata nel prefisso delle risposte ask incollate in TUI.
27
30
  replyLabel: 'human',
@@ -72,6 +75,10 @@ function envOverrides() {
72
75
  e.protectSharedTmuxServer = !['', '0', 'false', 'no', 'off']
73
76
  .includes(String(process.env.NEXUSCREW_PROTECT_SHARED_TMUX_SERVER).toLowerCase());
74
77
  }
78
+ if (process.env.NEXUSCREW_ALTERNATE_SCREEN !== undefined) {
79
+ e.alternateScreen = !['', '0', 'false', 'no', 'off']
80
+ .includes(String(process.env.NEXUSCREW_ALTERNATE_SCREEN).toLowerCase());
81
+ }
75
82
  if (process.env.NEXUSCREW_READONLY) e.readonlyDefault = process.env.NEXUSCREW_READONLY === '1';
76
83
  if (process.env.NEXUSCREW_REPLY_LABEL) e.replyLabel = process.env.NEXUSCREW_REPLY_LABEL;
77
84
  if (process.env.NEXUSCREW_FILES_ROOT) e.filesRoot = process.env.NEXUSCREW_FILES_ROOT;
@@ -47,7 +47,7 @@ const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
47
47
  // da builtin.js; httpError e' usato anche dal CRUD del facade.
48
48
  const { createBuiltinRuntime } = require('./runtime.js');
49
49
  const {
50
- composeLaunchArgv, composeClientInvocation, minimalEnv, promptCharsOk,
50
+ composeLaunchArgv, composeClientInvocation, alternateScreenArgs, minimalEnv, promptCharsOk,
51
51
  redactSecrets, sanitizeEarlyDiagnostic, waitStablePane, httpError, migrateLegacyTmuxSessions,
52
52
  } = require('./launch.js');
53
53
 
@@ -876,6 +876,7 @@ module.exports = {
876
876
  resolveCellCwd,
877
877
  composeLaunchArgv,
878
878
  composeClientInvocation,
879
+ alternateScreenArgs,
879
880
  minimalEnv,
880
881
  promptCharsOk,
881
882
  redactSecrets,
@@ -309,6 +309,21 @@ function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
309
309
  return ['new-session', '-d', '-s', tmuxSession, '-c', realCwd, child.command, ...child.args];
310
310
  }
311
311
 
312
+ // Pure: applica alternate-screen soltanto alla sessione Fleet appena creata.
313
+ // Il target exact-match protegge da nomi che condividono un prefisso; `-w`
314
+ // mantiene l'opzione window-local e non muta mai il server tmux globale.
315
+ // La hook e' necessaria perche' le finestre create dopo new-session non
316
+ // ereditano l'opzione della prima finestra.
317
+ function alternateScreenArgs(session, alternateScreen = false) {
318
+ if (!isTmuxSafeName(session)) return null;
319
+ if (alternateScreen !== false) return [];
320
+ const target = `=${session}:`;
321
+ return [
322
+ ['set-option', '-t', target, '-w', 'alternate-screen', 'off'],
323
+ ['set-hook', '-t', target, 'after-new-window', 'set-option -w alternate-screen off'],
324
+ ];
325
+ }
326
+
312
327
  // Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
313
328
  // e' viva entro la deadline, false altrimenti (command uscito / mai partita).
314
329
  async function waitAlive(tmuxBin, session, { env, readyMs }) {
@@ -380,6 +395,7 @@ module.exports = {
380
395
  promptCharsOk,
381
396
  composeClientInvocation,
382
397
  composeLaunchArgv,
398
+ alternateScreenArgs,
383
399
  migrateLegacyTmuxSessions,
384
400
  waitAlive,
385
401
  waitStablePane,
@@ -21,7 +21,7 @@ const {
21
21
  } = require('./managed.js');
22
22
  const {
23
23
  httpError, minimalEnv, tmuxExec,
24
- composeClientInvocation,
24
+ composeClientInvocation, alternateScreenArgs,
25
25
  waitAlive, waitStablePane, injectPrompt,
26
26
  redactSecrets, sanitizeEarlyDiagnostic,
27
27
  } = require('./launch.js');
@@ -246,6 +246,19 @@ function createBuiltinRuntime(ctx) {
246
246
  throw httpError(dup ? 409 : 500, why, null,
247
247
  { phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
248
248
  }
249
+ // Best effort per la sola sessione appena creata: non tocchiamo mai
250
+ // ~/.tmux.conf, opzioni globali o sessioni preesistenti. Un tmux che non
251
+ // accetta una delle opzioni non deve impedire l'avvio della cella.
252
+ const alternateSteps = alternateScreenArgs(cell.tmuxSession, cfg.alternateScreen);
253
+ if (alternateSteps) {
254
+ for (const args of alternateSteps) {
255
+ const configured = await tmuxExec(tmuxBin, args, { env: minimalEnv(), timeoutMs: 2000 });
256
+ if (configured.err) {
257
+ const log = typeof cfg.log === 'function' ? cfg.log : console.warn;
258
+ log(`fleet alternate-screen setup failed for ${cell.id}; continuing`);
259
+ }
260
+ }
261
+ }
249
262
  const createdIds = create.stdout.trim().split('\n')[0].split('\t');
250
263
  const sessionId = /^\$[0-9]+$/.test(createdIds[0] || '') ? createdIds[0] : '';
251
264
  const windowId = /^@[0-9]+$/.test(createdIds[1] || '') ? createdIds[1] : '';
package/lib/server.js CHANGED
@@ -273,7 +273,10 @@ function createServer(opts = {}) {
273
273
  try {
274
274
  const { name, cwd, preset } = req.body || {};
275
275
  await createSession(cfg.tmuxBin, { name, cwd, preset },
276
- { 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
+ });
277
280
  res.status(201).json({ created: true, name });
278
281
  } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
279
282
  });
@@ -67,7 +67,7 @@ const { createPairHandler } = require('./pairing-coordinator.js');
67
67
  const { publicPeeringRoutes } = require('./public-peering-routes.js');
68
68
 
69
69
  // Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
70
- const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
70
+ const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate', 'alternateScreen']);
71
71
  const ROLE_KEYS = new Set(['client', 'node']);
72
72
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
73
73
  const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
@@ -242,6 +242,9 @@ function settingsRoutes(deps = {}) {
242
242
  service,
243
243
  version: VERSION,
244
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,
245
248
  // Nome dispositivo proposto per i form di pairing (etichetta umana, non
246
249
  // lo slug). La UI lo precompila e lascia editing libero.
247
250
  deviceName,
@@ -263,10 +266,10 @@ function settingsRoutes(deps = {}) {
263
266
  return send(res, 400, { error: 'body deve essere un oggetto JSON' });
264
267
  }
265
268
  for (const k of Object.keys(b)) {
266
- 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)` });
267
270
  }
268
271
  if (Object.keys(b).length === 0) {
269
- 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)' });
270
273
  }
271
274
  if (b.roles !== undefined) {
272
275
  if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
@@ -296,6 +299,9 @@ function settingsRoutes(deps = {}) {
296
299
  if (b.autoUpdate !== undefined && typeof b.autoUpdate !== 'boolean') {
297
300
  return send(res, 400, { error: 'autoUpdate deve essere boolean' });
298
301
  }
302
+ if (b.alternateScreen !== undefined && typeof b.alternateScreen !== 'boolean') {
303
+ return send(res, 400, { error: 'alternateScreen deve essere boolean' });
304
+ }
299
305
 
300
306
  // merge sul config esistente (preserva le chiavi non gestite qui)
301
307
  const { cfg: current } = readConfigFile(configPath);
@@ -307,14 +313,23 @@ function settingsRoutes(deps = {}) {
307
313
  if (b.port !== undefined) next.port = b.port;
308
314
  if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
309
315
  if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
316
+ if (b.alternateScreen !== undefined) next.alternateScreen = b.alternateScreen;
310
317
  atomicWriteConfig(configPath, next);
311
318
  if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
312
319
  updater.setEnabled(b.autoUpdate);
313
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
+ }
314
326
 
315
327
  const out = {
316
328
  saved: true,
317
- 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
+ },
318
333
  };
319
334
  // Il server legge la porta SOLO allo startup: il cambio vale al prossimo
320
335
  // restart (contratto dichiarato nella risposta, la UI avvisa).
@@ -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.40",
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.